DPDK patches and discussions
 help / color / mirror / Atom feed
* [RFC PATCH v1 0/5] test case blocking and logging
@ 2023-12-20 10:33 Juraj Linkeš
  2023-12-20 10:33 ` [RFC PATCH v1 1/5] dts: convert dts.py methods to class Juraj Linkeš
                   ` (7 more replies)
  0 siblings, 8 replies; 28+ messages in thread
From: Juraj Linkeš @ 2023-12-20 10:33 UTC (permalink / raw)
  To: thomas, Honnappa.Nagarahalli, jspewock, probb, paul.szczepanek,
	yoan.picchi, Luca.Vizzarro
  Cc: dev, Juraj Linkeš

We currently don't store test cases that couldn't be executed because of
a previous failure, such as when a test suite setup failed, resulting in
no executed test cases.

In order to record the test cases that couldn't be executed, we must
know the lists of test suites and test cases ahead of the actual test
suite execution, as an error could occur before we even start executing
test suites.

In addition, the patch series contains two refactors.

The first refactor is closely related. The dts.py was renamed to
runner.py and given a clear purpose - running the test suites and all
other orchestration needed to run test suites. The logic for this was
not all in the original dts.py module and it was brought there. The
runner is also responsible for recording results, which is the blocked
test cases are recorded.

The other refactor, logging, is related to the first refactor. The
logging module was simplified while extending capabilities. Each test
suite logs into its own log file in addition to the main log file which
the runner must handle (as it knows when we start executing particular
test suites). The runner also handles the switching between execution
stages for the purposes of logging.

Juraj Linkeš (5):
  dts: convert dts.py methods to class
  dts: move test suite execution logic to DTSRunner
  dts: process test suites at the beginning of run
  dts: block all testcases when earlier setup fails
  dts: refactor logging configuration

 dts/framework/config/__init__.py              |   8 +-
 dts/framework/dts.py                          | 228 --------
 dts/framework/logger.py                       | 162 +++---
 dts/framework/remote_session/__init__.py      |   4 +-
 dts/framework/remote_session/os_session.py    |   6 +-
 .../remote_session/remote/__init__.py         |   7 +-
 .../remote/interactive_remote_session.py      |   7 +-
 .../remote/interactive_shell.py               |   7 +-
 .../remote_session/remote/remote_session.py   |   8 +-
 .../remote_session/remote/ssh_session.py      |   5 +-
 dts/framework/runner.py                       | 499 ++++++++++++++++++
 dts/framework/test_result.py                  | 365 +++++++------
 dts/framework/test_suite.py                   | 193 +------
 dts/framework/testbed_model/node.py           |  10 +-
 dts/framework/testbed_model/scapy.py          |   6 +-
 .../testbed_model/traffic_generator.py        |   5 +-
 dts/main.py                                   |  12 +-
 dts/tests/TestSuite_smoke_tests.py            |   2 +-
 18 files changed, 830 insertions(+), 704 deletions(-)
 delete mode 100644 dts/framework/dts.py
 create mode 100644 dts/framework/runner.py

-- 
2.34.1


^ permalink raw reply	[flat|nested] 28+ messages in thread

* [RFC PATCH v1 1/5] dts: convert dts.py methods to class
  2023-12-20 10:33 [RFC PATCH v1 0/5] test case blocking and logging Juraj Linkeš
@ 2023-12-20 10:33 ` Juraj Linkeš
  2023-12-20 10:33 ` [RFC PATCH v1 2/5] dts: move test suite execution logic to DTSRunner Juraj Linkeš
                   ` (6 subsequent siblings)
  7 siblings, 0 replies; 28+ messages in thread
From: Juraj Linkeš @ 2023-12-20 10:33 UTC (permalink / raw)
  To: thomas, Honnappa.Nagarahalli, jspewock, probb, paul.szczepanek,
	yoan.picchi, Luca.Vizzarro
  Cc: dev, Juraj Linkeš

The dts.py module deviates from the rest of the code without a clear
reason. Converting it into a class and using better naming will improve
organization and code readability.

Signed-off-by: Juraj Linkeš <juraj.linkes@pantheon.tech>
---
 dts/framework/config/__init__.py |   3 -
 dts/framework/dts.py             | 228 -----------------------------
 dts/framework/runner.py          | 243 +++++++++++++++++++++++++++++++
 dts/main.py                      |   6 +-
 4 files changed, 247 insertions(+), 233 deletions(-)
 delete mode 100644 dts/framework/dts.py
 create mode 100644 dts/framework/runner.py

diff --git a/dts/framework/config/__init__.py b/dts/framework/config/__init__.py
index 9b32cf0532..497847afb9 100644
--- a/dts/framework/config/__init__.py
+++ b/dts/framework/config/__init__.py
@@ -314,6 +314,3 @@ def load_config() -> Configuration:
     config: dict[str, Any] = warlock.model_factory(schema, name="_Config")(config_data)
     config_obj: Configuration = Configuration.from_dict(dict(config))
     return config_obj
-
-
-CONFIGURATION = load_config()
diff --git a/dts/framework/dts.py b/dts/framework/dts.py
deleted file mode 100644
index 25d6942d81..0000000000
--- a/dts/framework/dts.py
+++ /dev/null
@@ -1,228 +0,0 @@
-# SPDX-License-Identifier: BSD-3-Clause
-# Copyright(c) 2010-2019 Intel Corporation
-# Copyright(c) 2022-2023 PANTHEON.tech s.r.o.
-# Copyright(c) 2022-2023 University of New Hampshire
-
-import sys
-
-from .config import (
-    CONFIGURATION,
-    BuildTargetConfiguration,
-    ExecutionConfiguration,
-    TestSuiteConfig,
-)
-from .exception import BlockingTestSuiteError
-from .logger import DTSLOG, getLogger
-from .test_result import BuildTargetResult, DTSResult, ExecutionResult, Result
-from .test_suite import get_test_suites
-from .testbed_model import SutNode, TGNode
-from .utils import check_dts_python_version
-
-dts_logger: DTSLOG = getLogger("DTSRunner")
-result: DTSResult = DTSResult(dts_logger)
-
-
-def run_all() -> None:
-    """
-    The main process of DTS. Runs all build targets in all executions from the main
-    config file.
-    """
-    global dts_logger
-    global result
-
-    # check the python version of the server that run dts
-    check_dts_python_version()
-
-    sut_nodes: dict[str, SutNode] = {}
-    tg_nodes: dict[str, TGNode] = {}
-    try:
-        # for all Execution sections
-        for execution in CONFIGURATION.executions:
-            sut_node = sut_nodes.get(execution.system_under_test_node.name)
-            tg_node = tg_nodes.get(execution.traffic_generator_node.name)
-
-            try:
-                if not sut_node:
-                    sut_node = SutNode(execution.system_under_test_node)
-                    sut_nodes[sut_node.name] = sut_node
-                if not tg_node:
-                    tg_node = TGNode(execution.traffic_generator_node)
-                    tg_nodes[tg_node.name] = tg_node
-                result.update_setup(Result.PASS)
-            except Exception as e:
-                failed_node = execution.system_under_test_node.name
-                if sut_node:
-                    failed_node = execution.traffic_generator_node.name
-                dts_logger.exception(f"Creation of node {failed_node} failed.")
-                result.update_setup(Result.FAIL, e)
-
-            else:
-                _run_execution(sut_node, tg_node, execution, result)
-
-    except Exception as e:
-        dts_logger.exception("An unexpected error has occurred.")
-        result.add_error(e)
-        raise
-
-    finally:
-        try:
-            for node in (sut_nodes | tg_nodes).values():
-                node.close()
-            result.update_teardown(Result.PASS)
-        except Exception as e:
-            dts_logger.exception("Final cleanup of nodes failed.")
-            result.update_teardown(Result.ERROR, e)
-
-    # we need to put the sys.exit call outside the finally clause to make sure
-    # that unexpected exceptions will propagate
-    # in that case, the error that should be reported is the uncaught exception as
-    # that is a severe error originating from the framework
-    # at that point, we'll only have partial results which could be impacted by the
-    # error causing the uncaught exception, making them uninterpretable
-    _exit_dts()
-
-
-def _run_execution(
-    sut_node: SutNode,
-    tg_node: TGNode,
-    execution: ExecutionConfiguration,
-    result: DTSResult,
-) -> None:
-    """
-    Run the given execution. This involves running the execution setup as well as
-    running all build targets in the given execution.
-    """
-    dts_logger.info(f"Running execution with SUT '{execution.system_under_test_node.name}'.")
-    execution_result = result.add_execution(sut_node.config)
-    execution_result.add_sut_info(sut_node.node_info)
-
-    try:
-        sut_node.set_up_execution(execution)
-        execution_result.update_setup(Result.PASS)
-    except Exception as e:
-        dts_logger.exception("Execution setup failed.")
-        execution_result.update_setup(Result.FAIL, e)
-
-    else:
-        for build_target in execution.build_targets:
-            _run_build_target(sut_node, tg_node, build_target, execution, execution_result)
-
-    finally:
-        try:
-            sut_node.tear_down_execution()
-            execution_result.update_teardown(Result.PASS)
-        except Exception as e:
-            dts_logger.exception("Execution teardown failed.")
-            execution_result.update_teardown(Result.FAIL, e)
-
-
-def _run_build_target(
-    sut_node: SutNode,
-    tg_node: TGNode,
-    build_target: BuildTargetConfiguration,
-    execution: ExecutionConfiguration,
-    execution_result: ExecutionResult,
-) -> None:
-    """
-    Run the given build target.
-    """
-    dts_logger.info(f"Running build target '{build_target.name}'.")
-    build_target_result = execution_result.add_build_target(build_target)
-
-    try:
-        sut_node.set_up_build_target(build_target)
-        result.dpdk_version = sut_node.dpdk_version
-        build_target_result.add_build_target_info(sut_node.get_build_target_info())
-        build_target_result.update_setup(Result.PASS)
-    except Exception as e:
-        dts_logger.exception("Build target setup failed.")
-        build_target_result.update_setup(Result.FAIL, e)
-
-    else:
-        _run_all_suites(sut_node, tg_node, execution, build_target_result)
-
-    finally:
-        try:
-            sut_node.tear_down_build_target()
-            build_target_result.update_teardown(Result.PASS)
-        except Exception as e:
-            dts_logger.exception("Build target teardown failed.")
-            build_target_result.update_teardown(Result.FAIL, e)
-
-
-def _run_all_suites(
-    sut_node: SutNode,
-    tg_node: TGNode,
-    execution: ExecutionConfiguration,
-    build_target_result: BuildTargetResult,
-) -> None:
-    """
-    Use the given build_target to run execution's test suites
-    with possibly only a subset of test cases.
-    If no subset is specified, run all test cases.
-    """
-    end_build_target = False
-    if not execution.skip_smoke_tests:
-        execution.test_suites[:0] = [TestSuiteConfig.from_dict("smoke_tests")]
-    for test_suite_config in execution.test_suites:
-        try:
-            _run_single_suite(sut_node, tg_node, execution, build_target_result, test_suite_config)
-        except BlockingTestSuiteError as e:
-            dts_logger.exception(
-                f"An error occurred within {test_suite_config.test_suite}. Skipping build target."
-            )
-            result.add_error(e)
-            end_build_target = True
-        # if a blocking test failed and we need to bail out of suite executions
-        if end_build_target:
-            break
-
-
-def _run_single_suite(
-    sut_node: SutNode,
-    tg_node: TGNode,
-    execution: ExecutionConfiguration,
-    build_target_result: BuildTargetResult,
-    test_suite_config: TestSuiteConfig,
-) -> None:
-    """Runs a single test suite.
-
-    Args:
-        sut_node: Node to run tests on.
-        execution: Execution the test case belongs to.
-        build_target_result: Build target configuration test case is run on
-        test_suite_config: Test suite configuration
-
-    Raises:
-        BlockingTestSuiteError: If a test suite that was marked as blocking fails.
-    """
-    try:
-        full_suite_path = f"tests.TestSuite_{test_suite_config.test_suite}"
-        test_suite_classes = get_test_suites(full_suite_path)
-        suites_str = ", ".join((x.__name__ for x in test_suite_classes))
-        dts_logger.debug(f"Found test suites '{suites_str}' in '{full_suite_path}'.")
-    except Exception as e:
-        dts_logger.exception("An error occurred when searching for test suites.")
-        result.update_setup(Result.ERROR, e)
-
-    else:
-        for test_suite_class in test_suite_classes:
-            test_suite = test_suite_class(
-                sut_node,
-                tg_node,
-                test_suite_config.test_cases,
-                execution.func,
-                build_target_result,
-            )
-            test_suite.run()
-
-
-def _exit_dts() -> None:
-    """
-    Process all errors and exit with the proper exit code.
-    """
-    result.process()
-
-    if dts_logger:
-        dts_logger.info("DTS execution has ended.")
-    sys.exit(result.get_return_code())
diff --git a/dts/framework/runner.py b/dts/framework/runner.py
new file mode 100644
index 0000000000..5b077c5805
--- /dev/null
+++ b/dts/framework/runner.py
@@ -0,0 +1,243 @@
+# SPDX-License-Identifier: BSD-3-Clause
+# Copyright(c) 2010-2019 Intel Corporation
+# Copyright(c) 2022-2023 PANTHEON.tech s.r.o.
+# Copyright(c) 2022-2023 University of New Hampshire
+
+import logging
+import sys
+
+from .config import (
+    BuildTargetConfiguration,
+    Configuration,
+    ExecutionConfiguration,
+    TestSuiteConfig,
+)
+from .exception import BlockingTestSuiteError
+from .logger import DTSLOG, getLogger
+from .test_result import BuildTargetResult, DTSResult, ExecutionResult, Result
+from .test_suite import get_test_suites
+from .testbed_model import SutNode, TGNode
+from .utils import check_dts_python_version
+
+
+class DTSRunner:
+    _logger: DTSLOG
+    _result: DTSResult
+    _configuration: Configuration
+
+    def __init__(self, configuration: Configuration):
+        self._logger = getLogger("DTSRunner")
+        self._result = DTSResult(self._logger)
+        self._configuration = configuration
+
+    def run(self):
+        """
+        The main process of DTS. Runs all build targets in all executions from the main
+        config file.
+        """
+        # check the python version of the server that run dts
+        check_dts_python_version()
+        sut_nodes: dict[str, SutNode] = {}
+        tg_nodes: dict[str, TGNode] = {}
+        try:
+            # for all Execution sections
+            for execution in self._configuration.executions:
+                sut_node = sut_nodes.get(execution.system_under_test_node.name)
+                tg_node = tg_nodes.get(execution.traffic_generator_node.name)
+
+                try:
+                    if not sut_node:
+                        sut_node = SutNode(execution.system_under_test_node)
+                        sut_nodes[sut_node.name] = sut_node
+                    if not tg_node:
+                        tg_node = TGNode(execution.traffic_generator_node)
+                        tg_nodes[tg_node.name] = tg_node
+                    self._result.update_setup(Result.PASS)
+                except Exception as e:
+                    failed_node = execution.system_under_test_node.name
+                    if sut_node:
+                        failed_node = execution.traffic_generator_node.name
+                    self._logger.exception(
+                        f"The Creation of node {failed_node} failed."
+                    )
+                    self._result.update_setup(Result.FAIL, e)
+
+                else:
+                    self._run_execution(sut_node, tg_node, execution)
+
+        except Exception as e:
+            self._logger.exception("An unexpected error has occurred.")
+            self._result.add_error(e)
+            raise
+
+        finally:
+            try:
+                for node in (sut_nodes | tg_nodes).values():
+                    node.close()
+                self._result.update_teardown(Result.PASS)
+            except Exception as e:
+                self._logger.exception("The final cleanup of nodes failed.")
+                self._result.update_teardown(Result.ERROR, e)
+
+        # we need to put the sys.exit call outside the finally clause to make sure
+        # that unexpected exceptions will propagate
+        # in that case, the error that should be reported is the uncaught exception as
+        # that is a severe error originating from the framework
+        # at that point, we'll only have partial results which could be impacted by the
+        # error causing the uncaught exception, making them uninterpretable
+        self._exit_dts()
+
+    def _run_execution(
+        self,
+        sut_node: SutNode,
+        tg_node: TGNode,
+        execution: ExecutionConfiguration,
+    ) -> None:
+        """
+        Run the given execution. This involves running the execution setup as well as
+        running all build targets in the given execution.
+        """
+        self._logger.info(
+            f"Running execution with SUT '{execution.system_under_test_node.name}'."
+        )
+        execution_result = self._result.add_execution(sut_node.config)
+        execution_result.add_sut_info(sut_node.node_info)
+
+        try:
+            sut_node.set_up_execution(execution)
+            execution_result.update_setup(Result.PASS)
+        except Exception as e:
+            self._logger.exception("Execution setup failed.")
+            execution_result.update_setup(Result.FAIL, e)
+
+        else:
+            for build_target in execution.build_targets:
+                self._run_build_target(
+                    sut_node, tg_node, build_target, execution, execution_result
+                )
+
+        finally:
+            try:
+                sut_node.tear_down_execution()
+                execution_result.update_teardown(Result.PASS)
+            except Exception as e:
+                self._logger.exception("Execution teardown failed.")
+                execution_result.update_teardown(Result.FAIL, e)
+
+    def _run_build_target(
+        self,
+        sut_node: SutNode,
+        tg_node: TGNode,
+        build_target: BuildTargetConfiguration,
+        execution: ExecutionConfiguration,
+        execution_result: ExecutionResult,
+    ) -> None:
+        """
+        Run the given build target.
+        """
+        self._logger.info(f"Running build target '{build_target.name}'.")
+        build_target_result = execution_result.add_build_target(build_target)
+
+        try:
+            sut_node.set_up_build_target(build_target)
+            self._result.dpdk_version = sut_node.dpdk_version
+            build_target_result.add_build_target_info(sut_node.get_build_target_info())
+            build_target_result.update_setup(Result.PASS)
+        except Exception as e:
+            self._logger.exception("Build target setup failed.")
+            build_target_result.update_setup(Result.FAIL, e)
+
+        else:
+            self._run_all_suites(sut_node, tg_node, execution, build_target_result)
+
+        finally:
+            try:
+                sut_node.tear_down_build_target()
+                build_target_result.update_teardown(Result.PASS)
+            except Exception as e:
+                self._logger.exception("Build target teardown failed.")
+                build_target_result.update_teardown(Result.FAIL, e)
+
+    def _run_all_suites(
+        self,
+        sut_node: SutNode,
+        tg_node: TGNode,
+        execution: ExecutionConfiguration,
+        build_target_result: BuildTargetResult,
+    ) -> None:
+        """
+        Use the given build_target to run execution's test suites
+        with possibly only a subset of test cases.
+        If no subset is specified, run all test cases.
+        """
+        end_build_target = False
+        if not execution.skip_smoke_tests:
+            execution.test_suites[:0] = [TestSuiteConfig.from_dict("smoke_tests")]
+        for test_suite_config in execution.test_suites:
+            try:
+                self._run_single_suite(
+                    sut_node, tg_node, execution, build_target_result, test_suite_config
+                )
+            except BlockingTestSuiteError as e:
+                self._logger.exception(
+                    f"An error occurred within {test_suite_config.test_suite}. "
+                    "Skipping build target..."
+                )
+                self._result.add_error(e)
+                end_build_target = True
+            # if a blocking test failed and we need to bail out of suite executions
+            if end_build_target:
+                break
+
+    def _run_single_suite(
+        self,
+        sut_node: SutNode,
+        tg_node: TGNode,
+        execution: ExecutionConfiguration,
+        build_target_result: BuildTargetResult,
+        test_suite_config: TestSuiteConfig,
+    ) -> None:
+        """Runs a single test suite.
+
+        Args:
+            sut_node: Node to run tests on.
+            execution: Execution the test case belongs to.
+            build_target_result: Build target configuration test case is run on
+            test_suite_config: Test suite configuration
+
+        Raises:
+            BlockingTestSuiteError: If a test suite that was marked as blocking fails.
+        """
+        try:
+            full_suite_path = f"tests.TestSuite_{test_suite_config.test_suite}"
+            test_suite_classes = get_test_suites(full_suite_path)
+            suites_str = ", ".join((x.__name__ for x in test_suite_classes))
+            self._logger.debug(
+                f"Found test suites '{suites_str}' in '{full_suite_path}'."
+            )
+        except Exception as e:
+            self._logger.exception("An error occurred when searching for test suites.")
+            self._result.update_setup(Result.ERROR, e)
+
+        else:
+            for test_suite_class in test_suite_classes:
+                test_suite = test_suite_class(
+                    sut_node,
+                    tg_node,
+                    test_suite_config.test_cases,
+                    execution.func,
+                    build_target_result,
+                )
+                test_suite.run()
+
+    def _exit_dts(self) -> None:
+        """
+        Process all errors and exit with the proper exit code.
+        """
+        self._result.process()
+
+        if self._logger:
+            self._logger.info("DTS execution has ended.")
+
+        logging.shutdown()
+        sys.exit(self._result.get_return_code())
diff --git a/dts/main.py b/dts/main.py
index 43311fa847..879ce5cb89 100755
--- a/dts/main.py
+++ b/dts/main.py
@@ -10,11 +10,13 @@
 
 import logging
 
-from framework import dts
+from framework.config import load_config
+from framework.runner import DTSRunner
 
 
 def main() -> None:
-    dts.run_all()
+    dts = DTSRunner(configuration=load_config())
+    dts.run()
 
 
 # Main program begins here
-- 
2.34.1


^ permalink raw reply	[flat|nested] 28+ messages in thread

* [RFC PATCH v1 2/5] dts: move test suite execution logic to DTSRunner
  2023-12-20 10:33 [RFC PATCH v1 0/5] test case blocking and logging Juraj Linkeš
  2023-12-20 10:33 ` [RFC PATCH v1 1/5] dts: convert dts.py methods to class Juraj Linkeš
@ 2023-12-20 10:33 ` Juraj Linkeš
  2023-12-20 10:33 ` [RFC PATCH v1 3/5] dts: process test suites at the beginning of run Juraj Linkeš
                   ` (5 subsequent siblings)
  7 siblings, 0 replies; 28+ messages in thread
From: Juraj Linkeš @ 2023-12-20 10:33 UTC (permalink / raw)
  To: thomas, Honnappa.Nagarahalli, jspewock, probb, paul.szczepanek,
	yoan.picchi, Luca.Vizzarro
  Cc: dev, Juraj Linkeš

Move the code responsible for running the test suite from the
TestSuite class to the DTSRunner class. This restructuring decision
was made to consolidate and unify the related logic into a single unit.

Signed-off-by: Juraj Linkeš <juraj.linkes@pantheon.tech>
---
 dts/framework/runner.py     | 156 +++++++++++++++++++++++++++++++++---
 dts/framework/test_suite.py | 134 +------------------------------
 2 files changed, 147 insertions(+), 143 deletions(-)

diff --git a/dts/framework/runner.py b/dts/framework/runner.py
index 5b077c5805..5e145a8066 100644
--- a/dts/framework/runner.py
+++ b/dts/framework/runner.py
@@ -5,6 +5,7 @@
 
 import logging
 import sys
+from types import MethodType
 
 from .config import (
     BuildTargetConfiguration,
@@ -12,10 +13,18 @@
     ExecutionConfiguration,
     TestSuiteConfig,
 )
-from .exception import BlockingTestSuiteError
+from .exception import BlockingTestSuiteError, SSHTimeoutError, TestCaseVerifyError
 from .logger import DTSLOG, getLogger
-from .test_result import BuildTargetResult, DTSResult, ExecutionResult, Result
-from .test_suite import get_test_suites
+from .settings import SETTINGS
+from .test_result import (
+    BuildTargetResult,
+    DTSResult,
+    ExecutionResult,
+    Result,
+    TestCaseResult,
+    TestSuiteResult,
+)
+from .test_suite import TestSuite, get_test_suites
 from .testbed_model import SutNode, TGNode
 from .utils import check_dts_python_version
 
@@ -148,7 +157,7 @@ def _run_build_target(
             build_target_result.update_setup(Result.FAIL, e)
 
         else:
-            self._run_all_suites(sut_node, tg_node, execution, build_target_result)
+            self._run_test_suites(sut_node, tg_node, execution, build_target_result)
 
         finally:
             try:
@@ -158,7 +167,7 @@ def _run_build_target(
                 self._logger.exception("Build target teardown failed.")
                 build_target_result.update_teardown(Result.FAIL, e)
 
-    def _run_all_suites(
+    def _run_test_suites(
         self,
         sut_node: SutNode,
         tg_node: TGNode,
@@ -175,7 +184,7 @@ def _run_all_suites(
             execution.test_suites[:0] = [TestSuiteConfig.from_dict("smoke_tests")]
         for test_suite_config in execution.test_suites:
             try:
-                self._run_single_suite(
+                self._run_test_suite(
                     sut_node, tg_node, execution, build_target_result, test_suite_config
                 )
             except BlockingTestSuiteError as e:
@@ -189,7 +198,7 @@ def _run_all_suites(
             if end_build_target:
                 break
 
-    def _run_single_suite(
+    def _run_test_suite(
         self,
         sut_node: SutNode,
         tg_node: TGNode,
@@ -198,6 +207,9 @@ def _run_single_suite(
         test_suite_config: TestSuiteConfig,
     ) -> None:
         """Runs a single test suite.
+        Setup, execute and teardown the whole suite.
+        Suite execution consists of running all test cases scheduled to be executed.
+        A test cast run consists of setup, execution and teardown of said test case.
 
         Args:
             sut_node: Node to run tests on.
@@ -222,13 +234,131 @@ def _run_single_suite(
         else:
             for test_suite_class in test_suite_classes:
                 test_suite = test_suite_class(
-                    sut_node,
-                    tg_node,
-                    test_suite_config.test_cases,
-                    execution.func,
-                    build_target_result,
+                    sut_node, tg_node, test_suite_config.test_cases
+                )
+
+                test_suite_name = test_suite.__class__.__name__
+                test_suite_result = build_target_result.add_test_suite(test_suite_name)
+                try:
+                    self._logger.info(f"Starting test suite setup: {test_suite_name}")
+                    test_suite.set_up_suite()
+                    test_suite_result.update_setup(Result.PASS)
+                    self._logger.info(f"Test suite setup successful: {test_suite_name}")
+                except Exception as e:
+                    self._logger.exception(f"Test suite setup ERROR: {test_suite_name}")
+                    test_suite_result.update_setup(Result.ERROR, e)
+
+                else:
+                    self._execute_test_suite(
+                        execution.func, test_suite, test_suite_result
+                    )
+
+                finally:
+                    try:
+                        test_suite.tear_down_suite()
+                        sut_node.kill_cleanup_dpdk_apps()
+                        test_suite_result.update_teardown(Result.PASS)
+                    except Exception as e:
+                        self._logger.exception(
+                            f"Test suite teardown ERROR: {test_suite_name}"
+                        )
+                        self._logger.warning(
+                            f"Test suite '{test_suite_name}' teardown failed, "
+                            f"the next test suite may be affected."
+                        )
+                        test_suite_result.update_setup(Result.ERROR, e)
+                    if (
+                        len(test_suite_result.get_errors()) > 0
+                        and test_suite.is_blocking
+                    ):
+                        raise BlockingTestSuiteError(test_suite_name)
+
+    def _execute_test_suite(
+        self, func: bool, test_suite: TestSuite, test_suite_result: TestSuiteResult
+    ) -> None:
+        """
+        Execute all test cases scheduled to be executed in this suite.
+        """
+        if func:
+            for test_case_method in test_suite._get_functional_test_cases():
+                test_case_name = test_case_method.__name__
+                test_case_result = test_suite_result.add_test_case(test_case_name)
+                all_attempts = SETTINGS.re_run + 1
+                attempt_nr = 1
+                self._run_test_case(test_suite, test_case_method, test_case_result)
+                while not test_case_result and attempt_nr < all_attempts:
+                    attempt_nr += 1
+                    self._logger.info(
+                        f"Re-running FAILED test case '{test_case_name}'. "
+                        f"Attempt number {attempt_nr} out of {all_attempts}."
+                    )
+                    self._run_test_case(test_suite, test_case_method, test_case_result)
+
+    def _run_test_case(
+        self,
+        test_suite: TestSuite,
+        test_case_method: MethodType,
+        test_case_result: TestCaseResult,
+    ) -> None:
+        """
+        Setup, execute and teardown a test case in this suite.
+        Exceptions are caught and recorded in logs and results.
+        """
+        test_case_name = test_case_method.__name__
+
+        try:
+            # run set_up function for each case
+            test_suite.set_up_test_case()
+            test_case_result.update_setup(Result.PASS)
+        except SSHTimeoutError as e:
+            self._logger.exception(f"Test case setup FAILED: {test_case_name}")
+            test_case_result.update_setup(Result.FAIL, e)
+        except Exception as e:
+            self._logger.exception(f"Test case setup ERROR: {test_case_name}")
+            test_case_result.update_setup(Result.ERROR, e)
+
+        else:
+            # run test case if setup was successful
+            self._execute_test_case(test_case_method, test_case_result)
+
+        finally:
+            try:
+                test_suite.tear_down_test_case()
+                test_case_result.update_teardown(Result.PASS)
+            except Exception as e:
+                self._logger.exception(f"Test case teardown ERROR: {test_case_name}")
+                self._logger.warning(
+                    f"Test case '{test_case_name}' teardown failed, "
+                    f"the next test case may be affected."
                 )
-                test_suite.run()
+                test_case_result.update_teardown(Result.ERROR, e)
+                test_case_result.update(Result.ERROR)
+
+    def _execute_test_case(
+        self, test_case_method: MethodType, test_case_result: TestCaseResult
+    ) -> None:
+        """
+        Execute one test case and handle failures.
+        """
+        test_case_name = test_case_method.__name__
+        try:
+            self._logger.info(f"Starting test case execution: {test_case_name}")
+            test_case_method()
+            test_case_result.update(Result.PASS)
+            self._logger.info(f"Test case execution PASSED: {test_case_name}")
+
+        except TestCaseVerifyError as e:
+            self._logger.exception(f"Test case execution FAILED: {test_case_name}")
+            test_case_result.update(Result.FAIL, e)
+        except Exception as e:
+            self._logger.exception(f"Test case execution ERROR: {test_case_name}")
+            test_case_result.update(Result.ERROR, e)
+        except KeyboardInterrupt:
+            self._logger.error(
+                f"Test case execution INTERRUPTED by user: {test_case_name}"
+            )
+            test_case_result.update(Result.SKIP)
+            raise KeyboardInterrupt("Stop DTS")
 
     def _exit_dts(self) -> None:
         """
diff --git a/dts/framework/test_suite.py b/dts/framework/test_suite.py
index 4a7907ec33..e96305deb0 100644
--- a/dts/framework/test_suite.py
+++ b/dts/framework/test_suite.py
@@ -17,15 +17,9 @@
 from scapy.layers.l2 import Ether  # type: ignore[import]
 from scapy.packet import Packet, Padding  # type: ignore[import]
 
-from .exception import (
-    BlockingTestSuiteError,
-    ConfigurationError,
-    SSHTimeoutError,
-    TestCaseVerifyError,
-)
+from .exception import ConfigurationError, TestCaseVerifyError
 from .logger import DTSLOG, getLogger
 from .settings import SETTINGS
-from .test_result import BuildTargetResult, Result, TestCaseResult, TestSuiteResult
 from .testbed_model import SutNode, TGNode
 from .testbed_model.hw.port import Port, PortLink
 from .utils import get_packet_summaries
@@ -50,11 +44,10 @@ class TestSuite(object):
     """
 
     sut_node: SutNode
+    tg_node: TGNode
     is_blocking = False
     _logger: DTSLOG
     _test_cases_to_run: list[str]
-    _func: bool
-    _result: TestSuiteResult
     _port_links: list[PortLink]
     _sut_port_ingress: Port
     _sut_port_egress: Port
@@ -69,17 +62,13 @@ def __init__(
         self,
         sut_node: SutNode,
         tg_node: TGNode,
-        test_cases: list[str],
-        func: bool,
-        build_target_result: BuildTargetResult,
+        test_cases_to_run: list[str],
     ):
         self.sut_node = sut_node
         self.tg_node = tg_node
         self._logger = getLogger(self.__class__.__name__)
-        self._test_cases_to_run = test_cases
+        self._test_cases_to_run = test_cases_to_run
         self._test_cases_to_run.extend(SETTINGS.test_cases)
-        self._func = func
-        self._result = build_target_result.add_test_suite(self.__class__.__name__)
         self._port_links = []
         self._process_links()
         self._sut_port_ingress, self._tg_port_egress = (
@@ -280,60 +269,6 @@ def _verify_l3_packet(self, received_packet: IP, expected_packet: IP) -> bool:
             return False
         return True
 
-    def run(self) -> None:
-        """
-        Setup, execute and teardown the whole suite.
-        Suite execution consists of running all test cases scheduled to be executed.
-        A test cast run consists of setup, execution and teardown of said test case.
-        """
-        test_suite_name = self.__class__.__name__
-
-        try:
-            self._logger.info(f"Starting test suite setup: {test_suite_name}")
-            self.set_up_suite()
-            self._result.update_setup(Result.PASS)
-            self._logger.info(f"Test suite setup successful: {test_suite_name}")
-        except Exception as e:
-            self._logger.exception(f"Test suite setup ERROR: {test_suite_name}")
-            self._result.update_setup(Result.ERROR, e)
-
-        else:
-            self._execute_test_suite()
-
-        finally:
-            try:
-                self.tear_down_suite()
-                self.sut_node.kill_cleanup_dpdk_apps()
-                self._result.update_teardown(Result.PASS)
-            except Exception as e:
-                self._logger.exception(f"Test suite teardown ERROR: {test_suite_name}")
-                self._logger.warning(
-                    f"Test suite '{test_suite_name}' teardown failed, "
-                    f"the next test suite may be affected."
-                )
-                self._result.update_setup(Result.ERROR, e)
-            if len(self._result.get_errors()) > 0 and self.is_blocking:
-                raise BlockingTestSuiteError(test_suite_name)
-
-    def _execute_test_suite(self) -> None:
-        """
-        Execute all test cases scheduled to be executed in this suite.
-        """
-        if self._func:
-            for test_case_method in self._get_functional_test_cases():
-                test_case_name = test_case_method.__name__
-                test_case_result = self._result.add_test_case(test_case_name)
-                all_attempts = SETTINGS.re_run + 1
-                attempt_nr = 1
-                self._run_test_case(test_case_method, test_case_result)
-                while not test_case_result and attempt_nr < all_attempts:
-                    attempt_nr += 1
-                    self._logger.info(
-                        f"Re-running FAILED test case '{test_case_name}'. "
-                        f"Attempt number {attempt_nr} out of {all_attempts}."
-                    )
-                    self._run_test_case(test_case_method, test_case_result)
-
     def _get_functional_test_cases(self) -> list[MethodType]:
         """
         Get all functional test cases.
@@ -363,67 +298,6 @@ def _should_be_executed(self, test_case_name: str, test_case_regex: str) -> bool
 
         return match
 
-    def _run_test_case(
-        self, test_case_method: MethodType, test_case_result: TestCaseResult
-    ) -> None:
-        """
-        Setup, execute and teardown a test case in this suite.
-        Exceptions are caught and recorded in logs and results.
-        """
-        test_case_name = test_case_method.__name__
-
-        try:
-            # run set_up function for each case
-            self.set_up_test_case()
-            test_case_result.update_setup(Result.PASS)
-        except SSHTimeoutError as e:
-            self._logger.exception(f"Test case setup FAILED: {test_case_name}")
-            test_case_result.update_setup(Result.FAIL, e)
-        except Exception as e:
-            self._logger.exception(f"Test case setup ERROR: {test_case_name}")
-            test_case_result.update_setup(Result.ERROR, e)
-
-        else:
-            # run test case if setup was successful
-            self._execute_test_case(test_case_method, test_case_result)
-
-        finally:
-            try:
-                self.tear_down_test_case()
-                test_case_result.update_teardown(Result.PASS)
-            except Exception as e:
-                self._logger.exception(f"Test case teardown ERROR: {test_case_name}")
-                self._logger.warning(
-                    f"Test case '{test_case_name}' teardown failed, "
-                    f"the next test case may be affected."
-                )
-                test_case_result.update_teardown(Result.ERROR, e)
-                test_case_result.update(Result.ERROR)
-
-    def _execute_test_case(
-        self, test_case_method: MethodType, test_case_result: TestCaseResult
-    ) -> None:
-        """
-        Execute one test case and handle failures.
-        """
-        test_case_name = test_case_method.__name__
-        try:
-            self._logger.info(f"Starting test case execution: {test_case_name}")
-            test_case_method()
-            test_case_result.update(Result.PASS)
-            self._logger.info(f"Test case execution PASSED: {test_case_name}")
-
-        except TestCaseVerifyError as e:
-            self._logger.exception(f"Test case execution FAILED: {test_case_name}")
-            test_case_result.update(Result.FAIL, e)
-        except Exception as e:
-            self._logger.exception(f"Test case execution ERROR: {test_case_name}")
-            test_case_result.update(Result.ERROR, e)
-        except KeyboardInterrupt:
-            self._logger.error(f"Test case execution INTERRUPTED by user: {test_case_name}")
-            test_case_result.update(Result.SKIP)
-            raise KeyboardInterrupt("Stop DTS")
-
 
 def get_test_suites(testsuite_module_path: str) -> list[type[TestSuite]]:
     def is_test_suite(object) -> bool:
-- 
2.34.1


^ permalink raw reply	[flat|nested] 28+ messages in thread

* [RFC PATCH v1 3/5] dts: process test suites at the beginning of run
  2023-12-20 10:33 [RFC PATCH v1 0/5] test case blocking and logging Juraj Linkeš
  2023-12-20 10:33 ` [RFC PATCH v1 1/5] dts: convert dts.py methods to class Juraj Linkeš
  2023-12-20 10:33 ` [RFC PATCH v1 2/5] dts: move test suite execution logic to DTSRunner Juraj Linkeš
@ 2023-12-20 10:33 ` Juraj Linkeš
  2023-12-20 10:33 ` [RFC PATCH v1 4/5] dts: block all testcases when earlier setup fails Juraj Linkeš
                   ` (4 subsequent siblings)
  7 siblings, 0 replies; 28+ messages in thread
From: Juraj Linkeš @ 2023-12-20 10:33 UTC (permalink / raw)
  To: thomas, Honnappa.Nagarahalli, jspewock, probb, paul.szczepanek,
	yoan.picchi, Luca.Vizzarro
  Cc: dev, Juraj Linkeš

We initialize test suite/case objects at the start of
the program and store them with custom execution config
(add test case names) in Execution. This change helps
identify errors with test suites earlier, and we have
access to the right data when programs crash earlier.

Signed-off-by: Juraj Linkeš <juraj.linkes@pantheon.tech>
---
 dts/framework/config/__init__.py   |   5 +-
 dts/framework/runner.py            | 309 ++++++++++++++++++++---------
 dts/framework/test_suite.py        |  59 +-----
 dts/tests/TestSuite_smoke_tests.py |   2 +-
 4 files changed, 217 insertions(+), 158 deletions(-)

diff --git a/dts/framework/config/__init__.py b/dts/framework/config/__init__.py
index 497847afb9..d65ac625f8 100644
--- a/dts/framework/config/__init__.py
+++ b/dts/framework/config/__init__.py
@@ -238,7 +238,6 @@ class ExecutionConfiguration:
     system_under_test_node: SutNodeConfiguration
     traffic_generator_node: TGNodeConfiguration
     vdevs: list[str]
-    skip_smoke_tests: bool
 
     @staticmethod
     def from_dict(
@@ -247,9 +246,10 @@ def from_dict(
         build_targets: list[BuildTargetConfiguration] = list(
             map(BuildTargetConfiguration.from_dict, d["build_targets"])
         )
+        if not d.get("skip_smoke_tests", False):
+            d["test_suites"].insert(0, "smoke_tests")
         test_suites: list[TestSuiteConfig] = list(map(TestSuiteConfig.from_dict, d["test_suites"]))
         sut_name = d["system_under_test_node"]["node_name"]
-        skip_smoke_tests = d.get("skip_smoke_tests", False)
         assert sut_name in node_map, f"Unknown SUT {sut_name} in execution {d}"
         system_under_test_node = node_map[sut_name]
         assert isinstance(
@@ -270,7 +270,6 @@ def from_dict(
             build_targets=build_targets,
             perf=d["perf"],
             func=d["func"],
-            skip_smoke_tests=skip_smoke_tests,
             test_suites=test_suites,
             system_under_test_node=system_under_test_node,
             traffic_generator_node=traffic_generator_node,
diff --git a/dts/framework/runner.py b/dts/framework/runner.py
index 5e145a8066..acc3342f0c 100644
--- a/dts/framework/runner.py
+++ b/dts/framework/runner.py
@@ -3,9 +3,14 @@
 # Copyright(c) 2022-2023 PANTHEON.tech s.r.o.
 # Copyright(c) 2022-2023 University of New Hampshire
 
+import importlib
+import inspect
 import logging
+import re
 import sys
-from types import MethodType
+from copy import deepcopy
+from dataclasses import dataclass
+from types import MethodType, ModuleType
 
 from .config import (
     BuildTargetConfiguration,
@@ -13,7 +18,12 @@
     ExecutionConfiguration,
     TestSuiteConfig,
 )
-from .exception import BlockingTestSuiteError, SSHTimeoutError, TestCaseVerifyError
+from .exception import (
+    BlockingTestSuiteError,
+    ConfigurationError,
+    SSHTimeoutError,
+    TestCaseVerifyError,
+)
 from .logger import DTSLOG, getLogger
 from .settings import SETTINGS
 from .test_result import (
@@ -24,25 +34,55 @@
     TestCaseResult,
     TestSuiteResult,
 )
-from .test_suite import TestSuite, get_test_suites
+from .test_suite import TestSuite
 from .testbed_model import SutNode, TGNode
 from .utils import check_dts_python_version
 
 
+@dataclass
+class TestSuiteSetup:
+    test_suite: type[TestSuite]
+    test_cases: list[MethodType]
+
+    def processed_config(self) -> TestSuiteConfig:
+        return TestSuiteConfig(
+            test_suite=self.test_suite.__name__,
+            test_cases=[test_case.__name__ for test_case in self.test_cases],
+        )
+
+
+@dataclass
+class Execution:
+    config: ExecutionConfiguration
+    test_suite_setups: list[TestSuiteSetup]
+
+    def processed_config(self) -> ExecutionConfiguration:
+        """
+        Creating copy of execution config witch add test-case names.
+        """
+        modified_execution_config = deepcopy(self.config)
+        modified_execution_config.test_suites[:] = [
+            test_suite.processed_config() for test_suite in self.test_suite_setups
+        ]
+        return modified_execution_config
+
+
 class DTSRunner:
     _logger: DTSLOG
     _result: DTSResult
-    _configuration: Configuration
+    _executions: list[Execution]
 
     def __init__(self, configuration: Configuration):
         self._logger = getLogger("DTSRunner")
         self._result = DTSResult(self._logger)
-        self._configuration = configuration
+        self._executions = create_executions(configuration.executions)
 
     def run(self):
         """
         The main process of DTS. Runs all build targets in all executions from the main
         config file.
+        Suite execution consists of running all test cases scheduled to be executed.
+        A test case run consists of setup, execution and teardown of said test case.
         """
         # check the python version of the server that run dts
         check_dts_python_version()
@@ -50,22 +90,22 @@ def run(self):
         tg_nodes: dict[str, TGNode] = {}
         try:
             # for all Execution sections
-            for execution in self._configuration.executions:
-                sut_node = sut_nodes.get(execution.system_under_test_node.name)
-                tg_node = tg_nodes.get(execution.traffic_generator_node.name)
+            for execution in self._executions:
+                sut_node = sut_nodes.get(execution.config.system_under_test_node.name)
+                tg_node = tg_nodes.get(execution.config.traffic_generator_node.name)
 
                 try:
                     if not sut_node:
-                        sut_node = SutNode(execution.system_under_test_node)
+                        sut_node = SutNode(execution.config.system_under_test_node)
                         sut_nodes[sut_node.name] = sut_node
                     if not tg_node:
-                        tg_node = TGNode(execution.traffic_generator_node)
+                        tg_node = TGNode(execution.config.traffic_generator_node)
                         tg_nodes[tg_node.name] = tg_node
                     self._result.update_setup(Result.PASS)
                 except Exception as e:
-                    failed_node = execution.system_under_test_node.name
+                    failed_node = execution.config.system_under_test_node.name
                     if sut_node:
-                        failed_node = execution.traffic_generator_node.name
+                        failed_node = execution.config.traffic_generator_node.name
                     self._logger.exception(
                         f"The Creation of node {failed_node} failed."
                     )
@@ -100,29 +140,34 @@ def _run_execution(
         self,
         sut_node: SutNode,
         tg_node: TGNode,
-        execution: ExecutionConfiguration,
+        execution: Execution,
     ) -> None:
         """
         Run the given execution. This involves running the execution setup as well as
         running all build targets in the given execution.
         """
         self._logger.info(
-            f"Running execution with SUT '{execution.system_under_test_node.name}'."
+            "Running execution with SUT "
+            f"'{execution.config.system_under_test_node.name}'."
         )
         execution_result = self._result.add_execution(sut_node.config)
         execution_result.add_sut_info(sut_node.node_info)
 
         try:
-            sut_node.set_up_execution(execution)
+            sut_node.set_up_execution(execution.config)
             execution_result.update_setup(Result.PASS)
         except Exception as e:
             self._logger.exception("Execution setup failed.")
             execution_result.update_setup(Result.FAIL, e)
 
         else:
-            for build_target in execution.build_targets:
+            for build_target in execution.config.build_targets:
                 self._run_build_target(
-                    sut_node, tg_node, build_target, execution, execution_result
+                    sut_node,
+                    tg_node,
+                    build_target,
+                    execution,
+                    execution_result,
                 )
 
         finally:
@@ -138,7 +183,7 @@ def _run_build_target(
         sut_node: SutNode,
         tg_node: TGNode,
         build_target: BuildTargetConfiguration,
-        execution: ExecutionConfiguration,
+        execution: Execution,
         execution_result: ExecutionResult,
     ) -> None:
         """
@@ -171,7 +216,7 @@ def _run_test_suites(
         self,
         sut_node: SutNode,
         tg_node: TGNode,
-        execution: ExecutionConfiguration,
+        execution: Execution,
         build_target_result: BuildTargetResult,
     ) -> None:
         """
@@ -180,16 +225,18 @@ def _run_test_suites(
         If no subset is specified, run all test cases.
         """
         end_build_target = False
-        if not execution.skip_smoke_tests:
-            execution.test_suites[:0] = [TestSuiteConfig.from_dict("smoke_tests")]
-        for test_suite_config in execution.test_suites:
+        for test_suite_setup in execution.test_suite_setups:
             try:
                 self._run_test_suite(
-                    sut_node, tg_node, execution, build_target_result, test_suite_config
+                    sut_node,
+                    tg_node,
+                    test_suite_setup,
+                    build_target_result,
                 )
             except BlockingTestSuiteError as e:
                 self._logger.exception(
-                    f"An error occurred within {test_suite_config.test_suite}. "
+                    "An error occurred within "
+                    f"{test_suite_setup.test_suite.__name__}. "
                     "Skipping build target..."
                 )
                 self._result.add_error(e)
@@ -202,14 +249,10 @@ def _run_test_suite(
         self,
         sut_node: SutNode,
         tg_node: TGNode,
-        execution: ExecutionConfiguration,
+        test_suite_setup: TestSuiteSetup,
         build_target_result: BuildTargetResult,
-        test_suite_config: TestSuiteConfig,
     ) -> None:
         """Runs a single test suite.
-        Setup, execute and teardown the whole suite.
-        Suite execution consists of running all test cases scheduled to be executed.
-        A test cast run consists of setup, execution and teardown of said test case.
 
         Args:
             sut_node: Node to run tests on.
@@ -220,84 +263,67 @@ def _run_test_suite(
         Raises:
             BlockingTestSuiteError: If a test suite that was marked as blocking fails.
         """
+        test_suite = test_suite_setup.test_suite(sut_node, tg_node)
+        test_suite_name = test_suite_setup.test_suite.__name__
+        test_suite_result = build_target_result.add_test_suite(test_suite_name)
         try:
-            full_suite_path = f"tests.TestSuite_{test_suite_config.test_suite}"
-            test_suite_classes = get_test_suites(full_suite_path)
-            suites_str = ", ".join((x.__name__ for x in test_suite_classes))
-            self._logger.debug(
-                f"Found test suites '{suites_str}' in '{full_suite_path}'."
-            )
+            self._logger.info(f"Starting test suite setup: {test_suite_name}")
+            test_suite.set_up_suite()
+            test_suite_result.update_setup(Result.PASS)
+            self._logger.info(f"Test suite setup successful: {test_suite_name}")
         except Exception as e:
-            self._logger.exception("An error occurred when searching for test suites.")
-            self._result.update_setup(Result.ERROR, e)
+            self._logger.exception(f"Test suite setup ERROR: {test_suite_name}")
+            test_suite_result.update_setup(Result.ERROR, e)
 
         else:
-            for test_suite_class in test_suite_classes:
-                test_suite = test_suite_class(
-                    sut_node, tg_node, test_suite_config.test_cases
-                )
-
-                test_suite_name = test_suite.__class__.__name__
-                test_suite_result = build_target_result.add_test_suite(test_suite_name)
-                try:
-                    self._logger.info(f"Starting test suite setup: {test_suite_name}")
-                    test_suite.set_up_suite()
-                    test_suite_result.update_setup(Result.PASS)
-                    self._logger.info(f"Test suite setup successful: {test_suite_name}")
-                except Exception as e:
-                    self._logger.exception(f"Test suite setup ERROR: {test_suite_name}")
-                    test_suite_result.update_setup(Result.ERROR, e)
-
-                else:
-                    self._execute_test_suite(
-                        execution.func, test_suite, test_suite_result
-                    )
+            self._execute_test_suite(
+                test_suite,
+                test_suite_setup.test_cases,
+                test_suite_result,
+            )
 
-                finally:
-                    try:
-                        test_suite.tear_down_suite()
-                        sut_node.kill_cleanup_dpdk_apps()
-                        test_suite_result.update_teardown(Result.PASS)
-                    except Exception as e:
-                        self._logger.exception(
-                            f"Test suite teardown ERROR: {test_suite_name}"
-                        )
-                        self._logger.warning(
-                            f"Test suite '{test_suite_name}' teardown failed, "
-                            f"the next test suite may be affected."
-                        )
-                        test_suite_result.update_setup(Result.ERROR, e)
-                    if (
-                        len(test_suite_result.get_errors()) > 0
-                        and test_suite.is_blocking
-                    ):
-                        raise BlockingTestSuiteError(test_suite_name)
+        finally:
+            try:
+                test_suite.tear_down_suite()
+                sut_node.kill_cleanup_dpdk_apps()
+                test_suite_result.update_teardown(Result.PASS)
+            except Exception as e:
+                self._logger.exception(f"Test suite teardown ERROR: {test_suite_name}")
+                self._logger.warning(
+                    f"Test suite '{test_suite_name}' teardown failed, "
+                    "the next test suite may be affected."
+                )
+                test_suite_result.update_setup(Result.ERROR, e)
+            if len(test_suite_result.get_errors()) > 0 and test_suite.is_blocking:
+                raise BlockingTestSuiteError(test_suite_name)
 
     def _execute_test_suite(
-        self, func: bool, test_suite: TestSuite, test_suite_result: TestSuiteResult
+        self,
+        test_suite: TestSuite,
+        test_cases: list[MethodType],
+        test_suite_result: TestSuiteResult,
     ) -> None:
         """
         Execute all test cases scheduled to be executed in this suite.
         """
-        if func:
-            for test_case_method in test_suite._get_functional_test_cases():
-                test_case_name = test_case_method.__name__
-                test_case_result = test_suite_result.add_test_case(test_case_name)
-                all_attempts = SETTINGS.re_run + 1
-                attempt_nr = 1
-                self._run_test_case(test_suite, test_case_method, test_case_result)
-                while not test_case_result and attempt_nr < all_attempts:
-                    attempt_nr += 1
-                    self._logger.info(
-                        f"Re-running FAILED test case '{test_case_name}'. "
-                        f"Attempt number {attempt_nr} out of {all_attempts}."
-                    )
-                    self._run_test_case(test_suite, test_case_method, test_case_result)
+        for test_case_method in test_cases:
+            test_case_name = test_case_method.__name__
+            test_case_result = test_suite_result.add_test_case(test_case_name)
+            all_attempts = SETTINGS.re_run + 1
+            attempt_nr = 1
+            self._run_test_case(test_case_method, test_suite, test_case_result)
+            while not test_case_result and attempt_nr < all_attempts:
+                attempt_nr += 1
+                self._logger.info(
+                    f"Re-running FAILED test case '{test_case_name}'. "
+                    f"Attempt number {attempt_nr} out of {all_attempts}."
+                )
+                self._run_test_case(test_case_method, test_suite, test_case_result)
 
     def _run_test_case(
         self,
-        test_suite: TestSuite,
         test_case_method: MethodType,
+        test_suite: TestSuite,
         test_case_result: TestCaseResult,
     ) -> None:
         """
@@ -305,7 +331,6 @@ def _run_test_case(
         Exceptions are caught and recorded in logs and results.
         """
         test_case_name = test_case_method.__name__
-
         try:
             # run set_up function for each case
             test_suite.set_up_test_case()
@@ -319,7 +344,7 @@ def _run_test_case(
 
         else:
             # run test case if setup was successful
-            self._execute_test_case(test_case_method, test_case_result)
+            self._execute_test_case(test_case_method, test_suite, test_case_result)
 
         finally:
             try:
@@ -335,7 +360,10 @@ def _run_test_case(
                 test_case_result.update(Result.ERROR)
 
     def _execute_test_case(
-        self, test_case_method: MethodType, test_case_result: TestCaseResult
+        self,
+        test_case_method: MethodType,
+        test_suite: TestSuite,
+        test_case_result: TestCaseResult,
     ) -> None:
         """
         Execute one test case and handle failures.
@@ -343,7 +371,7 @@ def _execute_test_case(
         test_case_name = test_case_method.__name__
         try:
             self._logger.info(f"Starting test case execution: {test_case_name}")
-            test_case_method()
+            test_case_method(test_suite)
             test_case_result.update(Result.PASS)
             self._logger.info(f"Test case execution PASSED: {test_case_name}")
 
@@ -371,3 +399,92 @@ def _exit_dts(self) -> None:
 
         logging.shutdown()
         sys.exit(self._result.get_return_code())
+
+
+def create_executions(
+    execution_configs: list[ExecutionConfiguration],
+) -> list[Execution]:
+    executions: list[Execution] = []
+    for execution_config in execution_configs:
+        test_suite_setups: list[TestSuiteSetup] = []
+
+        for test_suite_config in execution_config.test_suites:
+            testsuite_module_path = f"tests.TestSuite_{test_suite_config.test_suite}"
+            try:
+                suite_module = importlib.import_module(testsuite_module_path)
+            except ModuleNotFoundError as e:
+                raise ConfigurationError(
+                    f"Test suite '{testsuite_module_path}' not found."
+                ) from e
+
+            test_suite = _get_suite_class(suite_module, test_suite_config.test_suite)
+
+            test_cases_to_run = test_suite_config.test_cases
+            test_cases_to_run.extend(SETTINGS.test_cases)
+
+            test_cases = []
+            if execution_config.func:
+                # add functional test cases
+                test_cases.extend(
+                    _get_test_cases(test_suite, r"test_(?!perf_)", test_cases_to_run)
+                )
+
+            if execution_config.perf:
+                # add performance test cases
+                test_cases.extend(
+                    _get_test_cases(test_suite, r"test_perf_", test_cases_to_run)
+                )
+
+            test_suite_setups.append(
+                TestSuiteSetup(test_suite=test_suite, test_cases=test_cases)
+            )
+
+        executions.append(
+            Execution(
+                config=execution_config,
+                test_suite_setups=test_suite_setups,
+            )
+        )
+
+    return executions
+
+
+def _get_suite_class(suite_module: ModuleType, suite_name: str) -> type[TestSuite]:
+    def is_test_suite(object) -> bool:
+        try:
+            if issubclass(object, TestSuite) and object is not TestSuite:
+                return True
+        except TypeError:
+            return False
+        return False
+
+    suite_name_regex = suite_name.replace("_", "").lower()
+    for class_name, suite_class in inspect.getmembers(suite_module, is_test_suite):
+        if not class_name.startswith("Test"):
+            continue
+
+        if suite_name_regex == class_name[4:].lower():
+            return suite_class
+    raise ConfigurationError(
+        f"Cannot find valid test suite in {suite_module.__name__}."
+    )
+
+
+def _get_test_cases(
+    suite_class: type[TestSuite], test_case_regex: str, test_cases_to_run: list[str]
+) -> list[MethodType]:
+    def should_be_executed(test_case_name: str) -> bool:
+        match = bool(re.match(test_case_regex, test_case_name))
+        if test_cases_to_run:
+            return match and test_case_name in test_cases_to_run
+
+        return match
+
+    test_cases = []
+    for test_case_name, test_case_method in inspect.getmembers(
+        suite_class, inspect.isfunction
+    ):
+        if should_be_executed(test_case_name):
+            test_cases.append(test_case_method)
+
+    return test_cases
diff --git a/dts/framework/test_suite.py b/dts/framework/test_suite.py
index e96305deb0..e73206993d 100644
--- a/dts/framework/test_suite.py
+++ b/dts/framework/test_suite.py
@@ -6,20 +6,15 @@
 Base class for creating DTS test cases.
 """
 
-import importlib
-import inspect
-import re
 from ipaddress import IPv4Interface, IPv6Interface, ip_interface
-from types import MethodType
 from typing import Union
 
 from scapy.layers.inet import IP  # type: ignore[import]
 from scapy.layers.l2 import Ether  # type: ignore[import]
 from scapy.packet import Packet, Padding  # type: ignore[import]
 
-from .exception import ConfigurationError, TestCaseVerifyError
+from .exception import TestCaseVerifyError
 from .logger import DTSLOG, getLogger
-from .settings import SETTINGS
 from .testbed_model import SutNode, TGNode
 from .testbed_model.hw.port import Port, PortLink
 from .utils import get_packet_summaries
@@ -47,7 +42,6 @@ class TestSuite(object):
     tg_node: TGNode
     is_blocking = False
     _logger: DTSLOG
-    _test_cases_to_run: list[str]
     _port_links: list[PortLink]
     _sut_port_ingress: Port
     _sut_port_egress: Port
@@ -62,13 +56,10 @@ def __init__(
         self,
         sut_node: SutNode,
         tg_node: TGNode,
-        test_cases_to_run: list[str],
     ):
         self.sut_node = sut_node
         self.tg_node = tg_node
         self._logger = getLogger(self.__class__.__name__)
-        self._test_cases_to_run = test_cases_to_run
-        self._test_cases_to_run.extend(SETTINGS.test_cases)
         self._port_links = []
         self._process_links()
         self._sut_port_ingress, self._tg_port_egress = (
@@ -268,51 +259,3 @@ def _verify_l3_packet(self, received_packet: IP, expected_packet: IP) -> bool:
         if received_packet.src != expected_packet.src or received_packet.dst != expected_packet.dst:
             return False
         return True
-
-    def _get_functional_test_cases(self) -> list[MethodType]:
-        """
-        Get all functional test cases.
-        """
-        return self._get_test_cases(r"test_(?!perf_)")
-
-    def _get_test_cases(self, test_case_regex: str) -> list[MethodType]:
-        """
-        Return a list of test cases matching test_case_regex.
-        """
-        self._logger.debug(f"Searching for test cases in {self.__class__.__name__}.")
-        filtered_test_cases = []
-        for test_case_name, test_case in inspect.getmembers(self, inspect.ismethod):
-            if self._should_be_executed(test_case_name, test_case_regex):
-                filtered_test_cases.append(test_case)
-        cases_str = ", ".join((x.__name__ for x in filtered_test_cases))
-        self._logger.debug(f"Found test cases '{cases_str}' in {self.__class__.__name__}.")
-        return filtered_test_cases
-
-    def _should_be_executed(self, test_case_name: str, test_case_regex: str) -> bool:
-        """
-        Check whether the test case should be executed.
-        """
-        match = bool(re.match(test_case_regex, test_case_name))
-        if self._test_cases_to_run:
-            return match and test_case_name in self._test_cases_to_run
-
-        return match
-
-
-def get_test_suites(testsuite_module_path: str) -> list[type[TestSuite]]:
-    def is_test_suite(object) -> bool:
-        try:
-            if issubclass(object, TestSuite) and object is not TestSuite:
-                return True
-        except TypeError:
-            return False
-        return False
-
-    try:
-        testcase_module = importlib.import_module(testsuite_module_path)
-    except ModuleNotFoundError as e:
-        raise ConfigurationError(f"Test suite '{testsuite_module_path}' not found.") from e
-    return [
-        test_suite_class
-        for _, test_suite_class in inspect.getmembers(testcase_module, is_test_suite)
-    ]
diff --git a/dts/tests/TestSuite_smoke_tests.py b/dts/tests/TestSuite_smoke_tests.py
index 8958f58dac..aa4bae5b17 100644
--- a/dts/tests/TestSuite_smoke_tests.py
+++ b/dts/tests/TestSuite_smoke_tests.py
@@ -10,7 +10,7 @@
 from framework.utils import REGEX_FOR_PCI_ADDRESS
 
 
-class SmokeTests(TestSuite):
+class TestSmokeTests(TestSuite):
     is_blocking = True
     # dicts in this list are expected to have two keys:
     # "pci_address" and "current_driver"
-- 
2.34.1


^ permalink raw reply	[flat|nested] 28+ messages in thread

* [RFC PATCH v1 4/5] dts: block all testcases when earlier setup fails
  2023-12-20 10:33 [RFC PATCH v1 0/5] test case blocking and logging Juraj Linkeš
                   ` (2 preceding siblings ...)
  2023-12-20 10:33 ` [RFC PATCH v1 3/5] dts: process test suites at the beginning of run Juraj Linkeš
@ 2023-12-20 10:33 ` Juraj Linkeš
  2023-12-20 10:33 ` [RFC PATCH v1 5/5] dts: refactor logging configuration Juraj Linkeš
                   ` (3 subsequent siblings)
  7 siblings, 0 replies; 28+ messages in thread
From: Juraj Linkeš @ 2023-12-20 10:33 UTC (permalink / raw)
  To: thomas, Honnappa.Nagarahalli, jspewock, probb, paul.szczepanek,
	yoan.picchi, Luca.Vizzarro
  Cc: dev, Juraj Linkeš

In case of a failure during execution, build target or suite setup
the test case results will be recorded as blocked. We also unify
methods add_<result> to add_child_result to be more consistently.
Now we store the corresponding config in each result with child
configs and parent result.

Signed-off-by: Juraj Linkeš <juraj.linkes@pantheon.tech>
---
 dts/framework/runner.py      |  12 +-
 dts/framework/test_result.py | 361 ++++++++++++++++++++---------------
 2 files changed, 216 insertions(+), 157 deletions(-)

diff --git a/dts/framework/runner.py b/dts/framework/runner.py
index acc3342f0c..28570d4a1c 100644
--- a/dts/framework/runner.py
+++ b/dts/framework/runner.py
@@ -74,7 +74,7 @@ class DTSRunner:
 
     def __init__(self, configuration: Configuration):
         self._logger = getLogger("DTSRunner")
-        self._result = DTSResult(self._logger)
+        self._result = DTSResult(configuration, self._logger)
         self._executions = create_executions(configuration.executions)
 
     def run(self):
@@ -150,7 +150,7 @@ def _run_execution(
             "Running execution with SUT "
             f"'{execution.config.system_under_test_node.name}'."
         )
-        execution_result = self._result.add_execution(sut_node.config)
+        execution_result = self._result.add_child_result(execution.processed_config())
         execution_result.add_sut_info(sut_node.node_info)
 
         try:
@@ -190,7 +190,7 @@ def _run_build_target(
         Run the given build target.
         """
         self._logger.info(f"Running build target '{build_target.name}'.")
-        build_target_result = execution_result.add_build_target(build_target)
+        build_target_result = execution_result.add_child_result(build_target)
 
         try:
             sut_node.set_up_build_target(build_target)
@@ -265,7 +265,9 @@ def _run_test_suite(
         """
         test_suite = test_suite_setup.test_suite(sut_node, tg_node)
         test_suite_name = test_suite_setup.test_suite.__name__
-        test_suite_result = build_target_result.add_test_suite(test_suite_name)
+        test_suite_result = build_target_result.add_child_result(
+            test_suite_setup.processed_config()
+        )
         try:
             self._logger.info(f"Starting test suite setup: {test_suite_name}")
             test_suite.set_up_suite()
@@ -308,7 +310,7 @@ def _execute_test_suite(
         """
         for test_case_method in test_cases:
             test_case_name = test_case_method.__name__
-            test_case_result = test_suite_result.add_test_case(test_case_name)
+            test_case_result = test_suite_result.add_child_result(test_case_name)
             all_attempts = SETTINGS.re_run + 1
             attempt_nr = 1
             self._run_test_case(test_case_method, test_suite, test_case_result)
diff --git a/dts/framework/test_result.py b/dts/framework/test_result.py
index 4c2e7e2418..dba2c55d36 100644
--- a/dts/framework/test_result.py
+++ b/dts/framework/test_result.py
@@ -9,6 +9,7 @@
 import os.path
 from collections.abc import MutableSequence
 from enum import Enum, auto
+from typing import Any, Union
 
 from .config import (
     OS,
@@ -16,9 +17,11 @@
     BuildTargetConfiguration,
     BuildTargetInfo,
     Compiler,
+    Configuration,
     CPUType,
-    NodeConfiguration,
+    ExecutionConfiguration,
     NodeInfo,
+    TestSuiteConfig,
 )
 from .exception import DTSError, ErrorSeverity
 from .logger import DTSLOG
@@ -35,6 +38,7 @@ class Result(Enum):
     FAIL = auto()
     ERROR = auto()
     SKIP = auto()
+    BLOCK = auto()
 
     def __bool__(self) -> bool:
         return self is self.PASS
@@ -63,42 +67,6 @@ def __bool__(self) -> bool:
         return bool(self.result)
 
 
-class Statistics(dict):
-    """
-    A helper class used to store the number of test cases by its result
-    along a few other basic information.
-    Using a dict provides a convenient way to format the data.
-    """
-
-    def __init__(self, dpdk_version: str | None):
-        super(Statistics, self).__init__()
-        for result in Result:
-            self[result.name] = 0
-        self["PASS RATE"] = 0.0
-        self["DPDK VERSION"] = dpdk_version
-
-    def __iadd__(self, other: Result) -> "Statistics":
-        """
-        Add a Result to the final count.
-        """
-        self[other.name] += 1
-        self["PASS RATE"] = (
-            float(self[Result.PASS.name]) * 100 / sum(self[result.name] for result in Result)
-        )
-        return self
-
-    def __str__(self) -> str:
-        """
-        Provide a string representation of the data.
-        """
-        stats_str = ""
-        for key, value in self.items():
-            stats_str += f"{key:<12} = {value}\n"
-            # according to docs, we should use \n when writing to text files
-            # on all platforms
-        return stats_str
-
-
 class BaseResult(object):
     """
     The Base class for all results. Stores the results of
@@ -109,6 +77,12 @@ class BaseResult(object):
     setup_result: FixtureResult
     teardown_result: FixtureResult
     _inner_results: MutableSequence["BaseResult"]
+    _child_configs: Union[
+        list[ExecutionConfiguration],
+        list[BuildTargetConfiguration],
+        list[TestSuiteConfig],
+        list[str],
+    ]
 
     def __init__(self):
         self.setup_result = FixtureResult()
@@ -119,6 +93,23 @@ def update_setup(self, result: Result, error: Exception | None = None) -> None:
         self.setup_result.result = result
         self.setup_result.error = error
 
+        if result in [Result.BLOCK, Result.ERROR, Result.FAIL]:
+            for child_config in self._child_configs:
+                child_result = self.add_child_result(child_config)
+                child_result.block()
+
+    def add_child_result(self, config: Any) -> "BaseResult":
+        """
+        Adding corresponding result for each classes.
+        """
+
+    def block(self):
+        """
+        Mark the result as block on corresponding classes.
+        """
+        self.update_setup(Result.BLOCK)
+        self.update_teardown(Result.BLOCK)
+
     def update_teardown(self, result: Result, error: Exception | None = None) -> None:
         self.teardown_result.result = result
         self.teardown_result.error = error
@@ -139,119 +130,11 @@ def _get_inner_errors(self) -> list[Exception]:
     def get_errors(self) -> list[Exception]:
         return self._get_setup_teardown_errors() + self._get_inner_errors()
 
-    def add_stats(self, statistics: Statistics) -> None:
+    def add_stats(self, statistics: "Statistics") -> None:
         for inner_result in self._inner_results:
             inner_result.add_stats(statistics)
 
 
-class TestCaseResult(BaseResult, FixtureResult):
-    """
-    The test case specific result.
-    Stores the result of the actual test case.
-    Also stores the test case name.
-    """
-
-    test_case_name: str
-
-    def __init__(self, test_case_name: str):
-        super(TestCaseResult, self).__init__()
-        self.test_case_name = test_case_name
-
-    def update(self, result: Result, error: Exception | None = None) -> None:
-        self.result = result
-        self.error = error
-
-    def _get_inner_errors(self) -> list[Exception]:
-        if self.error:
-            return [self.error]
-        return []
-
-    def add_stats(self, statistics: Statistics) -> None:
-        statistics += self.result
-
-    def __bool__(self) -> bool:
-        return bool(self.setup_result) and bool(self.teardown_result) and bool(self.result)
-
-
-class TestSuiteResult(BaseResult):
-    """
-    The test suite specific result.
-    The _inner_results list stores results of test cases in a given test suite.
-    Also stores the test suite name.
-    """
-
-    suite_name: str
-
-    def __init__(self, suite_name: str):
-        super(TestSuiteResult, self).__init__()
-        self.suite_name = suite_name
-
-    def add_test_case(self, test_case_name: str) -> TestCaseResult:
-        test_case_result = TestCaseResult(test_case_name)
-        self._inner_results.append(test_case_result)
-        return test_case_result
-
-
-class BuildTargetResult(BaseResult):
-    """
-    The build target specific result.
-    The _inner_results list stores results of test suites in a given build target.
-    Also stores build target specifics, such as compiler used to build DPDK.
-    """
-
-    arch: Architecture
-    os: OS
-    cpu: CPUType
-    compiler: Compiler
-    compiler_version: str | None
-    dpdk_version: str | None
-
-    def __init__(self, build_target: BuildTargetConfiguration):
-        super(BuildTargetResult, self).__init__()
-        self.arch = build_target.arch
-        self.os = build_target.os
-        self.cpu = build_target.cpu
-        self.compiler = build_target.compiler
-        self.compiler_version = None
-        self.dpdk_version = None
-
-    def add_build_target_info(self, versions: BuildTargetInfo) -> None:
-        self.compiler_version = versions.compiler_version
-        self.dpdk_version = versions.dpdk_version
-
-    def add_test_suite(self, test_suite_name: str) -> TestSuiteResult:
-        test_suite_result = TestSuiteResult(test_suite_name)
-        self._inner_results.append(test_suite_result)
-        return test_suite_result
-
-
-class ExecutionResult(BaseResult):
-    """
-    The execution specific result.
-    The _inner_results list stores results of build targets in a given execution.
-    Also stores the SUT node configuration.
-    """
-
-    sut_node: NodeConfiguration
-    sut_os_name: str
-    sut_os_version: str
-    sut_kernel_version: str
-
-    def __init__(self, sut_node: NodeConfiguration):
-        super(ExecutionResult, self).__init__()
-        self.sut_node = sut_node
-
-    def add_build_target(self, build_target: BuildTargetConfiguration) -> BuildTargetResult:
-        build_target_result = BuildTargetResult(build_target)
-        self._inner_results.append(build_target_result)
-        return build_target_result
-
-    def add_sut_info(self, sut_info: NodeInfo):
-        self.sut_os_name = sut_info.os_name
-        self.sut_os_version = sut_info.os_version
-        self.sut_kernel_version = sut_info.kernel_version
-
-
 class DTSResult(BaseResult):
     """
     Stores environment information and test results from a DTS run, which are:
@@ -269,25 +152,27 @@ class DTSResult(BaseResult):
     """
 
     dpdk_version: str | None
+    _child_configs: list[ExecutionConfiguration]
     _logger: DTSLOG
     _errors: list[Exception]
     _return_code: ErrorSeverity
-    _stats_result: Statistics | None
+    _stats_result: Union["Statistics", None]
     _stats_filename: str
 
-    def __init__(self, logger: DTSLOG):
+    def __init__(self, configuration: Configuration, logger: DTSLOG):
         super(DTSResult, self).__init__()
         self.dpdk_version = None
+        self._child_configs = configuration.executions
         self._logger = logger
         self._errors = []
         self._return_code = ErrorSeverity.NO_ERR
         self._stats_result = None
         self._stats_filename = os.path.join(SETTINGS.output_dir, "statistics.txt")
 
-    def add_execution(self, sut_node: NodeConfiguration) -> ExecutionResult:
-        execution_result = ExecutionResult(sut_node)
-        self._inner_results.append(execution_result)
-        return execution_result
+    def add_child_result(self, config: ExecutionConfiguration) -> "ExecutionResult":
+        result = ExecutionResult(config, self)
+        self._inner_results.append(result)
+        return result
 
     def add_error(self, error) -> None:
         self._errors.append(error)
@@ -325,3 +210,175 @@ def get_return_code(self) -> int:
                 self._return_code = error_return_code
 
         return int(self._return_code)
+
+
+class ExecutionResult(BaseResult):
+    """
+    The execution specific result.
+    The _inner_results list stores results of build targets in a given execution.
+    Also stores the SUT node configuration.
+    """
+
+    sut_os_name: str
+    sut_os_version: str
+    sut_kernel_version: str
+    _config: ExecutionConfiguration
+    _parent_result: DTSResult
+    _child_configs: list[BuildTargetConfiguration]
+
+    def __init__(self, config: ExecutionConfiguration, parent_result: DTSResult):
+        super(ExecutionResult, self).__init__()
+        self._config = config
+        self._parent_result = parent_result
+        self._child_configs = config.build_targets
+
+    def add_sut_info(self, sut_info: NodeInfo):
+        self.sut_os_name = sut_info.os_name
+        self.sut_os_version = sut_info.os_version
+        self.sut_kernel_version = sut_info.kernel_version
+
+    def add_child_result(self, config: BuildTargetConfiguration) -> "BuildTargetResult":
+        result = BuildTargetResult(config, self)
+        self._inner_results.append(result)
+        return result
+
+
+class BuildTargetResult(BaseResult):
+    """
+    The build target specific result.
+    The _inner_results list stores results of test suites in a given build target.
+    Also stores build target specifics, such as compiler used to build DPDK.
+    """
+
+    arch: Architecture
+    os: OS
+    cpu: CPUType
+    compiler: Compiler
+    compiler_version: str | None
+    dpdk_version: str | None
+    _config: BuildTargetConfiguration
+    _parent_result: ExecutionResult
+    _child_configs: list[TestSuiteConfig]
+
+    def __init__(
+        self, config: BuildTargetConfiguration, parent_result: ExecutionResult
+    ):
+        super(BuildTargetResult, self).__init__()
+        self.arch = config.arch
+        self.os = config.os
+        self.cpu = config.cpu
+        self.compiler = config.compiler
+        self.compiler_version = None
+        self.dpdk_version = None
+        self._config = config
+        self._parent_result = parent_result
+        self._child_configs = parent_result._config.test_suites
+
+    def add_build_target_info(self, versions: BuildTargetInfo) -> None:
+        self.compiler_version = versions.compiler_version
+        self.dpdk_version = versions.dpdk_version
+
+    def add_child_result(
+        self,
+        config: TestSuiteConfig,
+    ) -> "TestSuiteResult":
+        result = TestSuiteResult(config, self)
+        self._inner_results.append(result)
+        return result
+
+
+class TestSuiteResult(BaseResult):
+    """
+    The test suite specific result.
+    The _inner_results list stores results of test cases in a given test suite.
+    Also stores the test suite name.
+    """
+
+    _config: TestSuiteConfig
+    _parent_result: BuildTargetResult
+    _child_configs: list[str]
+
+    def __init__(self, config: TestSuiteConfig, parent_result: BuildTargetResult):
+        super(TestSuiteResult, self).__init__()
+        self._config = config
+        self._parent_result = parent_result
+        self._child_configs = config.test_cases
+
+    def add_child_result(self, config: str) -> "TestCaseResult":
+        result = TestCaseResult(config, self)
+        self._inner_results.append(result)
+        return result
+
+
+class TestCaseResult(BaseResult, FixtureResult):
+    """
+    The test case specific result.
+    Stores the result of the actual test case.
+    Also stores the test case name.
+    """
+
+    _config: str
+    _parent_result: TestSuiteResult
+
+    def __init__(self, config: str, parent_result: TestSuiteResult):
+        super(TestCaseResult, self).__init__()
+        self._config = config
+        self._parent_result = parent_result
+
+    def block(self):
+        self.update(Result.BLOCK)
+
+    def update(self, result: Result, error: Exception | None = None) -> None:
+        self.result = result
+        self.error = error
+
+    def _get_inner_errors(self) -> list[Exception]:
+        if self.error:
+            return [self.error]
+        return []
+
+    def add_stats(self, statistics: "Statistics") -> None:
+        statistics += self.result
+
+    def __bool__(self) -> bool:
+        return (
+            bool(self.setup_result) and bool(self.teardown_result) and bool(self.result)
+        )
+
+
+class Statistics(dict):
+    """
+    A helper class used to store the number of test cases by its result
+    along a few other basic information.
+    Using a dict provides a convenient way to format the data.
+    """
+
+    def __init__(self, dpdk_version: str | None):
+        super(Statistics, self).__init__()
+        for result in Result:
+            self[result.name] = 0
+        self["PASS RATE"] = 0.0
+        self["DPDK VERSION"] = dpdk_version
+
+    def __iadd__(self, other: Result) -> "Statistics":
+        """
+        Add a Result to the final count.
+        """
+        self[other.name] += 1
+        self["PASS RATE"] = (
+            float(self[Result.PASS.name])
+            * 100
+            / sum(self[result.name] for result in Result)
+        )
+        return self
+
+    def __str__(self) -> str:
+        """
+        Provide a string representation of the data.
+        """
+        stats_str = ""
+        for key, value in self.items():
+            stats_str += f"{key:<12} = {value}\n"
+            # according to docs, we should use \n when writing to text files
+            # on all platforms
+        return stats_str
-- 
2.34.1


^ permalink raw reply	[flat|nested] 28+ messages in thread

* [RFC PATCH v1 5/5] dts: refactor logging configuration
  2023-12-20 10:33 [RFC PATCH v1 0/5] test case blocking and logging Juraj Linkeš
                   ` (3 preceding siblings ...)
  2023-12-20 10:33 ` [RFC PATCH v1 4/5] dts: block all testcases when earlier setup fails Juraj Linkeš
@ 2023-12-20 10:33 ` Juraj Linkeš
  2024-01-08 18:47 ` [RFC PATCH v1 0/5] test case blocking and logging Jeremy Spewock
                   ` (2 subsequent siblings)
  7 siblings, 0 replies; 28+ messages in thread
From: Juraj Linkeš @ 2023-12-20 10:33 UTC (permalink / raw)
  To: thomas, Honnappa.Nagarahalli, jspewock, probb, paul.szczepanek,
	yoan.picchi, Luca.Vizzarro
  Cc: dev, Juraj Linkeš

Refactor logging for improved configuration and flexibility,
investigating unclear arguments and exploring alternatives
for logging test suites into separate files. In addition,
efforts were made to ensure that the modules remained
independent from the logger module, enabling potential use
by other consumers.

Signed-off-by: Juraj Linkeš <juraj.linkes@pantheon.tech>
---
 dts/framework/logger.py                       | 162 ++++++++----------
 dts/framework/remote_session/__init__.py      |   4 +-
 dts/framework/remote_session/os_session.py    |   6 +-
 .../remote_session/remote/__init__.py         |   7 +-
 .../remote/interactive_remote_session.py      |   7 +-
 .../remote/interactive_shell.py               |   7 +-
 .../remote_session/remote/remote_session.py   |   8 +-
 .../remote_session/remote/ssh_session.py      |   5 +-
 dts/framework/runner.py                       |  13 +-
 dts/framework/test_result.py                  |   6 +-
 dts/framework/test_suite.py                   |   6 +-
 dts/framework/testbed_model/node.py           |  10 +-
 dts/framework/testbed_model/scapy.py          |   6 +-
 .../testbed_model/traffic_generator.py        |   5 +-
 dts/main.py                                   |   6 +-
 15 files changed, 124 insertions(+), 134 deletions(-)

diff --git a/dts/framework/logger.py b/dts/framework/logger.py
index bb2991e994..43c49c2d03 100644
--- a/dts/framework/logger.py
+++ b/dts/framework/logger.py
@@ -10,108 +10,98 @@
 
 import logging
 import os.path
-from typing import TypedDict
-
-from .settings import SETTINGS
+from enum import Enum
+from logging import FileHandler, StreamHandler
+from pathlib import Path
 
 date_fmt = "%Y/%m/%d %H:%M:%S"
-stream_fmt = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
+stream_fmt = "%(asctime)s - %(stage)s - %(name)s - %(levelname)s - %(message)s"
 
 
-class LoggerDictType(TypedDict):
-    logger: "DTSLOG"
-    name: str
-    node: str
+def init_logger(verbose: bool, output_dir: str):
+    logging.raiseExceptions = False
 
+    DTSLog._output_dir = output_dir
+    logging.setLoggerClass(DTSLog)
 
-# List for saving all using loggers
-Loggers: list[LoggerDictType] = []
+    root_logger = logging.getLogger()
+    root_logger.setLevel(1)
 
+    sh = StreamHandler()
+    sh.setFormatter(logging.Formatter(stream_fmt, date_fmt))
+    sh.setLevel(logging.INFO)
+    if verbose:
+        sh.setLevel(logging.DEBUG)
+    root_logger.addHandler(sh)
 
-class DTSLOG(logging.LoggerAdapter):
-    """
-    DTS log class for framework and testsuite.
-    """
+    if not os.path.exists(output_dir):
+        os.mkdir(output_dir)
 
-    _logger: logging.Logger
-    node: str
-    sh: logging.StreamHandler
-    fh: logging.FileHandler
-    verbose_fh: logging.FileHandler
+    add_file_handlers(Path(output_dir, "dts"))
 
-    def __init__(self, logger: logging.Logger, node: str = "suite"):
-        self._logger = logger
-        # 1 means log everything, this will be used by file handlers if their level
-        # is not set
-        self._logger.setLevel(1)
 
-        self.node = node
+def add_file_handlers(log_file_path: Path) -> list[FileHandler]:
+    root_logger = logging.getLogger()
 
-        # add handler to emit to stdout
-        sh = logging.StreamHandler()
-        sh.setFormatter(logging.Formatter(stream_fmt, date_fmt))
-        sh.setLevel(logging.INFO)  # console handler default level
+    fh = FileHandler(f"{log_file_path}.log")
+    fh.setFormatter(logging.Formatter(stream_fmt, date_fmt))
+    root_logger.addHandler(fh)
 
-        if SETTINGS.verbose is True:
-            sh.setLevel(logging.DEBUG)
+    verbose_fh = FileHandler(f"{log_file_path}.verbose.log")
+    verbose_fh.setFormatter(
+        logging.Formatter(
+            "%(asctime)s|%(stage)s|%(name)s|%(levelname)s|%(pathname)s|%(lineno)d|"
+            "%(funcName)s|%(process)d|%(thread)d|%(threadName)s|%(message)s",
+            datefmt=date_fmt,
+        )
+    )
+    root_logger.addHandler(verbose_fh)
 
-        self._logger.addHandler(sh)
-        self.sh = sh
+    return [fh, verbose_fh]
 
-        # prepare the output folder
-        if not os.path.exists(SETTINGS.output_dir):
-            os.mkdir(SETTINGS.output_dir)
 
-        logging_path_prefix = os.path.join(SETTINGS.output_dir, node)
+class DtsStage(Enum):
+    pre_execution = "pre-execution"
+    execution = "execution"
+    build_target = "build-target"
+    suite = "suite"
+    post_execution = "post-execution"
 
-        fh = logging.FileHandler(f"{logging_path_prefix}.log")
-        fh.setFormatter(
-            logging.Formatter(
-                fmt="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
-                datefmt=date_fmt,
-            )
-        )
+    def __str__(self) -> str:
+        return self.value
 
-        self._logger.addHandler(fh)
-        self.fh = fh
-
-        # This outputs EVERYTHING, intended for post-mortem debugging
-        # Also optimized for processing via AWK (awk -F '|' ...)
-        verbose_fh = logging.FileHandler(f"{logging_path_prefix}.verbose.log")
-        verbose_fh.setFormatter(
-            logging.Formatter(
-                fmt="%(asctime)s|%(name)s|%(levelname)s|%(pathname)s|%(lineno)d|"
-                "%(funcName)s|%(process)d|%(thread)d|%(threadName)s|%(message)s",
-                datefmt=date_fmt,
-            )
-        )
 
-        self._logger.addHandler(verbose_fh)
-        self.verbose_fh = verbose_fh
-
-        super(DTSLOG, self).__init__(self._logger, dict(node=self.node))
-
-    def logger_exit(self) -> None:
-        """
-        Remove stream handler and logfile handler.
-        """
-        for handler in (self.sh, self.fh, self.verbose_fh):
-            handler.flush()
-            self._logger.removeHandler(handler)
-
-
-def getLogger(name: str, node: str = "suite") -> DTSLOG:
-    """
-    Get logger handler and if there's no handler for specified Node will create one.
-    """
-    global Loggers
-    # return saved logger
-    logger: LoggerDictType
-    for logger in Loggers:
-        if logger["name"] == name and logger["node"] == node:
-            return logger["logger"]
-
-    # return new logger
-    dts_logger: DTSLOG = DTSLOG(logging.getLogger(name), node)
-    Loggers.append({"logger": dts_logger, "name": name, "node": node})
-    return dts_logger
+class DTSLog(logging.Logger):
+    _stage: DtsStage = DtsStage.pre_execution
+    _extra_file_handlers: list[FileHandler] = []
+    _output_dir: None | str = None
+
+    def makeRecord(self, *args, **kwargs):
+        record = super().makeRecord(*args, **kwargs)
+        record.stage = DTSLog._stage
+        return record
+
+    def set_stage(self, stage: DtsStage, log_file_name: str | None = None):
+        self._remove_extra_file_handlers()
+
+        if DTSLog._stage != stage:
+            self.info(f"Moving from stage '{DTSLog._stage}' to stage '{stage}'.")
+            DTSLog._stage = stage
+
+        if log_file_name:
+            if DTSLog._output_dir:
+                DTSLog._extra_file_handlers.extend(
+                    add_file_handlers(Path(DTSLog._output_dir, log_file_name))
+                )
+            else:
+                self.warning(
+                    f"Cannot log '{DTSLog._stage}' stage in separate file, "
+                    "output dir is not defined."
+                )
+
+    def _remove_extra_file_handlers(self) -> None:
+        if DTSLog._extra_file_handlers:
+            for extra_file_handler in DTSLog._extra_file_handlers:
+                self.root.removeHandler(extra_file_handler)
+
+            DTSLog._extra_file_handlers = []
diff --git a/dts/framework/remote_session/__init__.py b/dts/framework/remote_session/__init__.py
index 6124417bd7..a4ec2f40ae 100644
--- a/dts/framework/remote_session/__init__.py
+++ b/dts/framework/remote_session/__init__.py
@@ -11,10 +11,10 @@
 """
 
 # pylama:ignore=W0611
+import logging
 
 from framework.config import OS, NodeConfiguration
 from framework.exception import ConfigurationError
-from framework.logger import DTSLOG
 
 from .linux_session import LinuxSession
 from .os_session import InteractiveShellType, OSSession
@@ -30,7 +30,7 @@
 )
 
 
-def create_session(node_config: NodeConfiguration, name: str, logger: DTSLOG) -> OSSession:
+def create_session(node_config: NodeConfiguration, name: str, logger: logging.Logger) -> OSSession:
     match node_config.os:
         case OS.linux:
             return LinuxSession(node_config, name, logger)
diff --git a/dts/framework/remote_session/os_session.py b/dts/framework/remote_session/os_session.py
index 8a709eac1c..2524a4e669 100644
--- a/dts/framework/remote_session/os_session.py
+++ b/dts/framework/remote_session/os_session.py
@@ -2,6 +2,7 @@
 # Copyright(c) 2023 PANTHEON.tech s.r.o.
 # Copyright(c) 2023 University of New Hampshire
 
+import logging
 from abc import ABC, abstractmethod
 from collections.abc import Iterable
 from ipaddress import IPv4Interface, IPv6Interface
@@ -9,7 +10,6 @@
 from typing import Type, TypeVar, Union
 
 from framework.config import Architecture, NodeConfiguration, NodeInfo
-from framework.logger import DTSLOG
 from framework.remote_session.remote import InteractiveShell
 from framework.settings import SETTINGS
 from framework.testbed_model import LogicalCore
@@ -36,7 +36,7 @@ class OSSession(ABC):
 
     _config: NodeConfiguration
     name: str
-    _logger: DTSLOG
+    _logger: logging.Logger
     remote_session: RemoteSession
     interactive_session: InteractiveRemoteSession
 
@@ -44,7 +44,7 @@ def __init__(
         self,
         node_config: NodeConfiguration,
         name: str,
-        logger: DTSLOG,
+        logger: logging.Logger,
     ):
         self._config = node_config
         self.name = name
diff --git a/dts/framework/remote_session/remote/__init__.py b/dts/framework/remote_session/remote/__init__.py
index 06403691a5..4a22155153 100644
--- a/dts/framework/remote_session/remote/__init__.py
+++ b/dts/framework/remote_session/remote/__init__.py
@@ -4,8 +4,9 @@
 
 # pylama:ignore=W0611
 
+import logging
+
 from framework.config import NodeConfiguration
-from framework.logger import DTSLOG
 
 from .interactive_remote_session import InteractiveRemoteSession
 from .interactive_shell import InteractiveShell
@@ -16,12 +17,12 @@
 
 
 def create_remote_session(
-    node_config: NodeConfiguration, name: str, logger: DTSLOG
+    node_config: NodeConfiguration, name: str, logger: logging.Logger
 ) -> RemoteSession:
     return SSHSession(node_config, name, logger)
 
 
 def create_interactive_session(
-    node_config: NodeConfiguration, logger: DTSLOG
+    node_config: NodeConfiguration, logger: logging.Logger
 ) -> InteractiveRemoteSession:
     return InteractiveRemoteSession(node_config, logger)
diff --git a/dts/framework/remote_session/remote/interactive_remote_session.py b/dts/framework/remote_session/remote/interactive_remote_session.py
index 098ded1bb0..bf0996a747 100644
--- a/dts/framework/remote_session/remote/interactive_remote_session.py
+++ b/dts/framework/remote_session/remote/interactive_remote_session.py
@@ -2,7 +2,7 @@
 # Copyright(c) 2023 University of New Hampshire
 
 """Handler for an SSH session dedicated to interactive shells."""
-
+import logging
 import socket
 import traceback
 
@@ -16,7 +16,6 @@
 
 from framework.config import NodeConfiguration
 from framework.exception import SSHConnectionError
-from framework.logger import DTSLOG
 
 
 class InteractiveRemoteSession:
@@ -54,11 +53,11 @@ class InteractiveRemoteSession:
     username: str
     password: str
     session: SSHClient
-    _logger: DTSLOG
+    _logger: logging.Logger
     _node_config: NodeConfiguration
     _transport: Transport | None
 
-    def __init__(self, node_config: NodeConfiguration, _logger: DTSLOG) -> None:
+    def __init__(self, node_config: NodeConfiguration, _logger: logging.Logger) -> None:
         self._node_config = node_config
         self._logger = _logger
         self.hostname = node_config.hostname
diff --git a/dts/framework/remote_session/remote/interactive_shell.py b/dts/framework/remote_session/remote/interactive_shell.py
index 4db19fb9b3..b6074838c2 100644
--- a/dts/framework/remote_session/remote/interactive_shell.py
+++ b/dts/framework/remote_session/remote/interactive_shell.py
@@ -11,14 +11,13 @@
 elevated privileges to start it is expected that the method for gaining those
 privileges is provided when initializing the class.
 """
-
+import logging
 from abc import ABC
 from pathlib import PurePath
 from typing import Callable
 
 from paramiko import Channel, SSHClient, channel  # type: ignore[import]
 
-from framework.logger import DTSLOG
 from framework.settings import SETTINGS
 
 
@@ -58,7 +57,7 @@ class InteractiveShell(ABC):
     _stdin: channel.ChannelStdinFile
     _stdout: channel.ChannelFile
     _ssh_channel: Channel
-    _logger: DTSLOG
+    _logger: logging.Logger
     _timeout: float
     _app_args: str
     _default_prompt: str = ""
@@ -69,7 +68,7 @@ class InteractiveShell(ABC):
     def __init__(
         self,
         interactive_session: SSHClient,
-        logger: DTSLOG,
+        logger: logging.Logger,
         get_privileged_command: Callable[[str], str] | None,
         app_args: str = "",
         timeout: float = SETTINGS.timeout,
diff --git a/dts/framework/remote_session/remote/remote_session.py b/dts/framework/remote_session/remote/remote_session.py
index 719f7d1ef7..da78e5c921 100644
--- a/dts/framework/remote_session/remote/remote_session.py
+++ b/dts/framework/remote_session/remote/remote_session.py
@@ -2,14 +2,13 @@
 # Copyright(c) 2010-2014 Intel Corporation
 # Copyright(c) 2022-2023 PANTHEON.tech s.r.o.
 # Copyright(c) 2022-2023 University of New Hampshire
-
 import dataclasses
+import logging
 from abc import ABC, abstractmethod
 from pathlib import PurePath
 
 from framework.config import NodeConfiguration
 from framework.exception import RemoteCommandExecutionError
-from framework.logger import DTSLOG
 from framework.settings import SETTINGS
 
 
@@ -50,14 +49,14 @@ class RemoteSession(ABC):
     username: str
     password: str
     history: list[CommandResult]
-    _logger: DTSLOG
+    _logger: logging.Logger
     _node_config: NodeConfiguration
 
     def __init__(
         self,
         node_config: NodeConfiguration,
         session_name: str,
-        logger: DTSLOG,
+        logger: logging.Logger,
     ):
         self._node_config = node_config
 
@@ -120,7 +119,6 @@ def close(self, force: bool = False) -> None:
         """
         Close the remote session and free all used resources.
         """
-        self._logger.logger_exit()
         self._close(force)
 
     @abstractmethod
diff --git a/dts/framework/remote_session/remote/ssh_session.py b/dts/framework/remote_session/remote/ssh_session.py
index 1a7ee649ab..42441c4587 100644
--- a/dts/framework/remote_session/remote/ssh_session.py
+++ b/dts/framework/remote_session/remote/ssh_session.py
@@ -1,6 +1,6 @@
 # SPDX-License-Identifier: BSD-3-Clause
 # Copyright(c) 2023 PANTHEON.tech s.r.o.
-
+import logging
 import socket
 import traceback
 from pathlib import PurePath
@@ -20,7 +20,6 @@
 
 from framework.config import NodeConfiguration
 from framework.exception import SSHConnectionError, SSHSessionDeadError, SSHTimeoutError
-from framework.logger import DTSLOG
 
 from .remote_session import CommandResult, RemoteSession
 
@@ -49,7 +48,7 @@ def __init__(
         self,
         node_config: NodeConfiguration,
         session_name: str,
-        logger: DTSLOG,
+        logger: logging.Logger,
     ):
         super(SSHSession, self).__init__(node_config, session_name, logger)
 
diff --git a/dts/framework/runner.py b/dts/framework/runner.py
index 28570d4a1c..5c06e4ca1a 100644
--- a/dts/framework/runner.py
+++ b/dts/framework/runner.py
@@ -11,6 +11,7 @@
 from copy import deepcopy
 from dataclasses import dataclass
 from types import MethodType, ModuleType
+from typing import cast
 
 from .config import (
     BuildTargetConfiguration,
@@ -24,7 +25,7 @@
     SSHTimeoutError,
     TestCaseVerifyError,
 )
-from .logger import DTSLOG, getLogger
+from .logger import DTSLog, DtsStage
 from .settings import SETTINGS
 from .test_result import (
     BuildTargetResult,
@@ -68,12 +69,12 @@ def processed_config(self) -> ExecutionConfiguration:
 
 
 class DTSRunner:
-    _logger: DTSLOG
+    _logger: DTSLog
     _result: DTSResult
     _executions: list[Execution]
 
     def __init__(self, configuration: Configuration):
-        self._logger = getLogger("DTSRunner")
+        self._logger = cast(DTSLog, logging.getLogger("DTSRunner"))
         self._result = DTSResult(configuration, self._logger)
         self._executions = create_executions(configuration.executions)
 
@@ -146,6 +147,7 @@ def _run_execution(
         Run the given execution. This involves running the execution setup as well as
         running all build targets in the given execution.
         """
+        self._logger.set_stage(DtsStage.execution)
         self._logger.info(
             "Running execution with SUT "
             f"'{execution.config.system_under_test_node.name}'."
@@ -175,6 +177,7 @@ def _run_execution(
                 sut_node.tear_down_execution()
                 execution_result.update_teardown(Result.PASS)
             except Exception as e:
+                self._logger.set_stage(DtsStage.execution)
                 self._logger.exception("Execution teardown failed.")
                 execution_result.update_teardown(Result.FAIL, e)
 
@@ -189,6 +192,7 @@ def _run_build_target(
         """
         Run the given build target.
         """
+        self._logger.set_stage(DtsStage.build_target)
         self._logger.info(f"Running build target '{build_target.name}'.")
         build_target_result = execution_result.add_child_result(build_target)
 
@@ -209,6 +213,7 @@ def _run_build_target(
                 sut_node.tear_down_build_target()
                 build_target_result.update_teardown(Result.PASS)
             except Exception as e:
+                self._logger.set_stage(DtsStage.build_target)
                 self._logger.exception("Build target teardown failed.")
                 build_target_result.update_teardown(Result.FAIL, e)
 
@@ -265,6 +270,7 @@ def _run_test_suite(
         """
         test_suite = test_suite_setup.test_suite(sut_node, tg_node)
         test_suite_name = test_suite_setup.test_suite.__name__
+        self._logger.set_stage(DtsStage.suite, test_suite_name)
         test_suite_result = build_target_result.add_child_result(
             test_suite_setup.processed_config()
         )
@@ -397,6 +403,7 @@ def _exit_dts(self) -> None:
         self._result.process()
 
         if self._logger:
+            self._logger.set_stage(DtsStage.post_execution)
             self._logger.info("DTS execution has ended.")
 
         logging.shutdown()
diff --git a/dts/framework/test_result.py b/dts/framework/test_result.py
index dba2c55d36..221e75205e 100644
--- a/dts/framework/test_result.py
+++ b/dts/framework/test_result.py
@@ -6,6 +6,7 @@
 Generic result container and reporters
 """
 
+import logging
 import os.path
 from collections.abc import MutableSequence
 from enum import Enum, auto
@@ -24,7 +25,6 @@
     TestSuiteConfig,
 )
 from .exception import DTSError, ErrorSeverity
-from .logger import DTSLOG
 from .settings import SETTINGS
 
 
@@ -153,13 +153,13 @@ class DTSResult(BaseResult):
 
     dpdk_version: str | None
     _child_configs: list[ExecutionConfiguration]
-    _logger: DTSLOG
+    _logger: logging.Logger
     _errors: list[Exception]
     _return_code: ErrorSeverity
     _stats_result: Union["Statistics", None]
     _stats_filename: str
 
-    def __init__(self, configuration: Configuration, logger: DTSLOG):
+    def __init__(self, configuration: Configuration, logger: logging.Logger):
         super(DTSResult, self).__init__()
         self.dpdk_version = None
         self._child_configs = configuration.executions
diff --git a/dts/framework/test_suite.py b/dts/framework/test_suite.py
index e73206993d..9c9a8c1e08 100644
--- a/dts/framework/test_suite.py
+++ b/dts/framework/test_suite.py
@@ -6,6 +6,7 @@
 Base class for creating DTS test cases.
 """
 
+import logging
 from ipaddress import IPv4Interface, IPv6Interface, ip_interface
 from typing import Union
 
@@ -14,7 +15,6 @@
 from scapy.packet import Packet, Padding  # type: ignore[import]
 
 from .exception import TestCaseVerifyError
-from .logger import DTSLOG, getLogger
 from .testbed_model import SutNode, TGNode
 from .testbed_model.hw.port import Port, PortLink
 from .utils import get_packet_summaries
@@ -41,7 +41,7 @@ class TestSuite(object):
     sut_node: SutNode
     tg_node: TGNode
     is_blocking = False
-    _logger: DTSLOG
+    _logger: logging.Logger
     _port_links: list[PortLink]
     _sut_port_ingress: Port
     _sut_port_egress: Port
@@ -59,7 +59,7 @@ def __init__(
     ):
         self.sut_node = sut_node
         self.tg_node = tg_node
-        self._logger = getLogger(self.__class__.__name__)
+        self._logger = logging.getLogger(self.__class__.__name__)
         self._port_links = []
         self._process_links()
         self._sut_port_ingress, self._tg_port_egress = (
diff --git a/dts/framework/testbed_model/node.py b/dts/framework/testbed_model/node.py
index ef700d8114..a98c58df4f 100644
--- a/dts/framework/testbed_model/node.py
+++ b/dts/framework/testbed_model/node.py
@@ -6,7 +6,7 @@
 """
 A node is a generic host that DTS connects to and manages.
 """
-
+import logging
 from abc import ABC
 from ipaddress import IPv4Interface, IPv6Interface
 from typing import Any, Callable, Type, Union
@@ -16,7 +16,6 @@
     ExecutionConfiguration,
     NodeConfiguration,
 )
-from framework.logger import DTSLOG, getLogger
 from framework.remote_session import InteractiveShellType, OSSession, create_session
 from framework.settings import SETTINGS
 
@@ -43,7 +42,7 @@ class Node(ABC):
     name: str
     lcores: list[LogicalCore]
     ports: list[Port]
-    _logger: DTSLOG
+    _logger: logging.Logger
     _other_sessions: list[OSSession]
     _execution_config: ExecutionConfiguration
     virtual_devices: list[VirtualDevice]
@@ -51,7 +50,7 @@ class Node(ABC):
     def __init__(self, node_config: NodeConfiguration):
         self.config = node_config
         self.name = node_config.name
-        self._logger = getLogger(self.name)
+        self._logger = logging.getLogger(self.name)
         self.main_session = create_session(self.config, self.name, self._logger)
 
         self._logger.info(f"Connected to node: {self.name}")
@@ -137,7 +136,7 @@ def create_session(self, name: str) -> OSSession:
         connection = create_session(
             self.config,
             session_name,
-            getLogger(session_name, node=self.name),
+            logging.getLogger(session_name),
         )
         self._other_sessions.append(connection)
         return connection
@@ -237,7 +236,6 @@ def close(self) -> None:
             self.main_session.close()
         for session in self._other_sessions:
             session.close()
-        self._logger.logger_exit()
 
     @staticmethod
     def skip_setup(func: Callable[..., Any]) -> Callable[..., Any]:
diff --git a/dts/framework/testbed_model/scapy.py b/dts/framework/testbed_model/scapy.py
index 9083e92b3d..61058cd38a 100644
--- a/dts/framework/testbed_model/scapy.py
+++ b/dts/framework/testbed_model/scapy.py
@@ -13,6 +13,7 @@
 """
 
 import inspect
+import logging
 import marshal
 import time
 import types
@@ -24,7 +25,6 @@
 from scapy.packet import Packet  # type: ignore[import]
 
 from framework.config import OS, ScapyTrafficGeneratorConfig
-from framework.logger import DTSLOG, getLogger
 from framework.remote_session import PythonShell
 from framework.settings import SETTINGS
 
@@ -190,12 +190,12 @@ class ScapyTrafficGenerator(CapturingTrafficGenerator):
     rpc_server_proxy: xmlrpc.client.ServerProxy
     _config: ScapyTrafficGeneratorConfig
     _tg_node: TGNode
-    _logger: DTSLOG
+    _logger: logging.Logger
 
     def __init__(self, tg_node: TGNode, config: ScapyTrafficGeneratorConfig):
         self._config = config
         self._tg_node = tg_node
-        self._logger = getLogger(f"{self._tg_node.name} {self._config.traffic_generator_type}")
+        self._logger = logging.getLogger(f"{self._tg_node.name} {self._config.traffic_generator_type}")
 
         assert (
             self._tg_node.config.os == OS.linux
diff --git a/dts/framework/testbed_model/traffic_generator.py b/dts/framework/testbed_model/traffic_generator.py
index 28c35d3ce4..6b0838958a 100644
--- a/dts/framework/testbed_model/traffic_generator.py
+++ b/dts/framework/testbed_model/traffic_generator.py
@@ -7,12 +7,11 @@
 These traffic generators can't capture received traffic,
 only count the number of received packets.
 """
-
+import logging
 from abc import ABC, abstractmethod
 
 from scapy.packet import Packet  # type: ignore[import]
 
-from framework.logger import DTSLOG
 from framework.utils import get_packet_summaries
 
 from .hw.port import Port
@@ -24,7 +23,7 @@ class TrafficGenerator(ABC):
     Defines the few basic methods that each traffic generator must implement.
     """
 
-    _logger: DTSLOG
+    _logger: logging.Logger
 
     def send_packet(self, packet: Packet, port: Port) -> None:
         """Send a packet and block until it is fully sent.
diff --git a/dts/main.py b/dts/main.py
index 879ce5cb89..f2828148f0 100755
--- a/dts/main.py
+++ b/dts/main.py
@@ -8,18 +8,18 @@
 A test framework for testing DPDK.
 """
 
-import logging
-
 from framework.config import load_config
+from framework.logger import init_logger
 from framework.runner import DTSRunner
+from framework.settings import SETTINGS
 
 
 def main() -> None:
+    init_logger(SETTINGS.verbose, SETTINGS.output_dir)
     dts = DTSRunner(configuration=load_config())
     dts.run()
 
 
 # Main program begins here
 if __name__ == "__main__":
-    logging.raiseExceptions = True
     main()
-- 
2.34.1


^ permalink raw reply	[flat|nested] 28+ messages in thread

* Re: [RFC PATCH v1 0/5] test case blocking and logging
  2023-12-20 10:33 [RFC PATCH v1 0/5] test case blocking and logging Juraj Linkeš
                   ` (4 preceding siblings ...)
  2023-12-20 10:33 ` [RFC PATCH v1 5/5] dts: refactor logging configuration Juraj Linkeš
@ 2024-01-08 18:47 ` Jeremy Spewock
  2024-02-06 14:57 ` [PATCH v2 0/7] " Juraj Linkeš
  2024-02-23  7:54 ` [PATCH v3 0/7] test case blocking and logging Juraj Linkeš
  7 siblings, 0 replies; 28+ messages in thread
From: Jeremy Spewock @ 2024-01-08 18:47 UTC (permalink / raw)
  To: Juraj Linkeš
  Cc: thomas, Honnappa.Nagarahalli, probb, paul.szczepanek,
	yoan.picchi, Luca.Vizzarro, dev

[-- Attachment #1: Type: text/plain, Size: 85 bytes --]

Definitely worth-while changes. I looked them over and it all looks good so
far.

+1

[-- Attachment #2: Type: text/html, Size: 323 bytes --]

^ permalink raw reply	[flat|nested] 28+ messages in thread

* [PATCH v2 0/7] test case blocking and logging
  2023-12-20 10:33 [RFC PATCH v1 0/5] test case blocking and logging Juraj Linkeš
                   ` (5 preceding siblings ...)
  2024-01-08 18:47 ` [RFC PATCH v1 0/5] test case blocking and logging Jeremy Spewock
@ 2024-02-06 14:57 ` Juraj Linkeš
  2024-02-06 14:57   ` [PATCH v2 1/7] dts: convert dts.py methods to class Juraj Linkeš
                     ` (6 more replies)
  2024-02-23  7:54 ` [PATCH v3 0/7] test case blocking and logging Juraj Linkeš
  7 siblings, 7 replies; 28+ messages in thread
From: Juraj Linkeš @ 2024-02-06 14:57 UTC (permalink / raw)
  To: thomas, Honnappa.Nagarahalli, jspewock, probb, paul.szczepanek,
	Luca.Vizzarro
  Cc: dev, Juraj Linkeš

We currently don't record test case results that couldn't be executed
because of a previous failure, such as when a test suite setup failed,
resulting in no executed test cases.

In order to record the test cases that couldn't be executed, we must
know the lists of test suites and test cases ahead of the actual test
suite execution, as an error could occur before we even start executing
test suites.

In addition, the patch series contains two refactors and one
improvement.

The first refactor is closely related. The dts.py was renamed to
runner.py and given a clear purpose - running the test suites and all
other orchestration needed to run test suites. The logic for this was
not all in the original dts.py module and it was brought there. The
runner is also responsible for recording results, which is the blocked
test cases are recorded.

The other refactor, logging, is related to the first refactor. The
logging module was simplified while extending capabilities. Each test
suite logs into its own log file in addition to the main log file which
the runner must handle (as it knows when we start executing particular
test suites). The runner also handles the switching between execution
stages for the purposes of logging.

The one aforementioned improvement is in unifying how we specify test
cases in the conf.yaml file and in the environment variable/command line
argument.

v2:
Rebase and update of the whole patch. There are changes in all parts of
the code, mainly improving the design and logic.
Also added the last patch which improves test suite specification on the
cmdline.

Juraj Linkeš (7):
  dts: convert dts.py methods to class
  dts: move test suite execution logic to DTSRunner
  dts: filter test suites in executions
  dts: reorganize test result
  dts: block all test cases when earlier setup fails
  dts: refactor logging configuration
  dts: improve test suite and case filtering

 doc/guides/tools/dts.rst                      |  14 +-
 dts/framework/config/__init__.py              |  36 +-
 dts/framework/config/conf_yaml_schema.json    |   2 +-
 dts/framework/dts.py                          | 338 ---------
 dts/framework/logger.py                       | 235 +++---
 dts/framework/remote_session/__init__.py      |   6 +-
 .../interactive_remote_session.py             |   6 +-
 .../remote_session/interactive_shell.py       |   6 +-
 .../remote_session/remote_session.py          |   8 +-
 dts/framework/runner.py                       | 701 ++++++++++++++++++
 dts/framework/settings.py                     | 188 +++--
 dts/framework/test_result.py                  | 565 ++++++++------
 dts/framework/test_suite.py                   | 239 +-----
 dts/framework/testbed_model/node.py           |  11 +-
 dts/framework/testbed_model/os_session.py     |   7 +-
 .../traffic_generator/traffic_generator.py    |   6 +-
 dts/main.py                                   |   9 +-
 dts/pyproject.toml                            |   3 +
 dts/tests/TestSuite_smoke_tests.py            |   2 +-
 19 files changed, 1354 insertions(+), 1028 deletions(-)
 delete mode 100644 dts/framework/dts.py
 create mode 100644 dts/framework/runner.py

-- 
2.34.1


^ permalink raw reply	[flat|nested] 28+ messages in thread

* [PATCH v2 1/7] dts: convert dts.py methods to class
  2024-02-06 14:57 ` [PATCH v2 0/7] " Juraj Linkeš
@ 2024-02-06 14:57   ` Juraj Linkeš
  2024-02-06 14:57   ` [PATCH v2 2/7] dts: move test suite execution logic to DTSRunner Juraj Linkeš
                     ` (5 subsequent siblings)
  6 siblings, 0 replies; 28+ messages in thread
From: Juraj Linkeš @ 2024-02-06 14:57 UTC (permalink / raw)
  To: thomas, Honnappa.Nagarahalli, jspewock, probb, paul.szczepanek,
	Luca.Vizzarro
  Cc: dev, Juraj Linkeš

The dts.py module deviates from the rest of the code without a clear
reason. Converting it into a class and using better naming will improve
organization and code readability.

Signed-off-by: Juraj Linkeš <juraj.linkes@pantheon.tech>
---
 dts/framework/dts.py    | 338 ----------------------------------------
 dts/framework/runner.py | 333 +++++++++++++++++++++++++++++++++++++++
 dts/main.py             |   6 +-
 3 files changed, 337 insertions(+), 340 deletions(-)
 delete mode 100644 dts/framework/dts.py
 create mode 100644 dts/framework/runner.py

diff --git a/dts/framework/dts.py b/dts/framework/dts.py
deleted file mode 100644
index e16d4578a0..0000000000
--- a/dts/framework/dts.py
+++ /dev/null
@@ -1,338 +0,0 @@
-# SPDX-License-Identifier: BSD-3-Clause
-# Copyright(c) 2010-2019 Intel Corporation
-# Copyright(c) 2022-2023 PANTHEON.tech s.r.o.
-# Copyright(c) 2022-2023 University of New Hampshire
-
-r"""Test suite runner module.
-
-A DTS run is split into stages:
-
-    #. Execution stage,
-    #. Build target stage,
-    #. Test suite stage,
-    #. Test case stage.
-
-The module is responsible for running tests on testbeds defined in the test run configuration.
-Each setup or teardown of each stage is recorded in a :class:`~.test_result.DTSResult` or
-one of its subclasses. The test case results are also recorded.
-
-If an error occurs, the current stage is aborted, the error is recorded and the run continues in
-the next iteration of the same stage. The return code is the highest `severity` of all
-:class:`~.exception.DTSError`\s.
-
-Example:
-    An error occurs in a build target setup. The current build target is aborted and the run
-    continues with the next build target. If the errored build target was the last one in the given
-    execution, the next execution begins.
-
-Attributes:
-    dts_logger: The logger instance used in this module.
-    result: The top level result used in the module.
-"""
-
-import sys
-
-from .config import (
-    BuildTargetConfiguration,
-    ExecutionConfiguration,
-    TestSuiteConfig,
-    load_config,
-)
-from .exception import BlockingTestSuiteError
-from .logger import DTSLOG, getLogger
-from .test_result import BuildTargetResult, DTSResult, ExecutionResult, Result
-from .test_suite import get_test_suites
-from .testbed_model import SutNode, TGNode
-
-# dummy defaults to satisfy linters
-dts_logger: DTSLOG = None  # type: ignore[assignment]
-result: DTSResult = DTSResult(dts_logger)
-
-
-def run_all() -> None:
-    """Run all build targets in all executions from the test run configuration.
-
-    Before running test suites, executions and build targets are first set up.
-    The executions and build targets defined in the test run configuration are iterated over.
-    The executions define which tests to run and where to run them and build targets define
-    the DPDK build setup.
-
-    The tests suites are set up for each execution/build target tuple and each scheduled
-    test case within the test suite is set up, executed and torn down. After all test cases
-    have been executed, the test suite is torn down and the next build target will be tested.
-
-    All the nested steps look like this:
-
-        #. Execution setup
-
-            #. Build target setup
-
-                #. Test suite setup
-
-                    #. Test case setup
-                    #. Test case logic
-                    #. Test case teardown
-
-                #. Test suite teardown
-
-            #. Build target teardown
-
-        #. Execution teardown
-
-    The test cases are filtered according to the specification in the test run configuration and
-    the :option:`--test-cases` command line argument or
-    the :envvar:`DTS_TESTCASES` environment variable.
-    """
-    global dts_logger
-    global result
-
-    # create a regular DTS logger and create a new result with it
-    dts_logger = getLogger("DTSRunner")
-    result = DTSResult(dts_logger)
-
-    # check the python version of the server that run dts
-    _check_dts_python_version()
-
-    sut_nodes: dict[str, SutNode] = {}
-    tg_nodes: dict[str, TGNode] = {}
-    try:
-        # for all Execution sections
-        for execution in load_config().executions:
-            sut_node = sut_nodes.get(execution.system_under_test_node.name)
-            tg_node = tg_nodes.get(execution.traffic_generator_node.name)
-
-            try:
-                if not sut_node:
-                    sut_node = SutNode(execution.system_under_test_node)
-                    sut_nodes[sut_node.name] = sut_node
-                if not tg_node:
-                    tg_node = TGNode(execution.traffic_generator_node)
-                    tg_nodes[tg_node.name] = tg_node
-                result.update_setup(Result.PASS)
-            except Exception as e:
-                failed_node = execution.system_under_test_node.name
-                if sut_node:
-                    failed_node = execution.traffic_generator_node.name
-                dts_logger.exception(f"Creation of node {failed_node} failed.")
-                result.update_setup(Result.FAIL, e)
-
-            else:
-                _run_execution(sut_node, tg_node, execution, result)
-
-    except Exception as e:
-        dts_logger.exception("An unexpected error has occurred.")
-        result.add_error(e)
-        raise
-
-    finally:
-        try:
-            for node in (sut_nodes | tg_nodes).values():
-                node.close()
-            result.update_teardown(Result.PASS)
-        except Exception as e:
-            dts_logger.exception("Final cleanup of nodes failed.")
-            result.update_teardown(Result.ERROR, e)
-
-    # we need to put the sys.exit call outside the finally clause to make sure
-    # that unexpected exceptions will propagate
-    # in that case, the error that should be reported is the uncaught exception as
-    # that is a severe error originating from the framework
-    # at that point, we'll only have partial results which could be impacted by the
-    # error causing the uncaught exception, making them uninterpretable
-    _exit_dts()
-
-
-def _check_dts_python_version() -> None:
-    """Check the required Python version - v3.10."""
-
-    def RED(text: str) -> str:
-        return f"\u001B[31;1m{str(text)}\u001B[0m"
-
-    if sys.version_info.major < 3 or (sys.version_info.major == 3 and sys.version_info.minor < 10):
-        print(
-            RED(
-                (
-                    "WARNING: DTS execution node's python version is lower than"
-                    "python 3.10, is deprecated and will not work in future releases."
-                )
-            ),
-            file=sys.stderr,
-        )
-        print(RED("Please use Python >= 3.10 instead"), file=sys.stderr)
-
-
-def _run_execution(
-    sut_node: SutNode,
-    tg_node: TGNode,
-    execution: ExecutionConfiguration,
-    result: DTSResult,
-) -> None:
-    """Run the given execution.
-
-    This involves running the execution setup as well as running all build targets
-    in the given execution. After that, execution teardown is run.
-
-    Args:
-        sut_node: The execution's SUT node.
-        tg_node: The execution's TG node.
-        execution: An execution's test run configuration.
-        result: The top level result object.
-    """
-    dts_logger.info(f"Running execution with SUT '{execution.system_under_test_node.name}'.")
-    execution_result = result.add_execution(sut_node.config)
-    execution_result.add_sut_info(sut_node.node_info)
-
-    try:
-        sut_node.set_up_execution(execution)
-        execution_result.update_setup(Result.PASS)
-    except Exception as e:
-        dts_logger.exception("Execution setup failed.")
-        execution_result.update_setup(Result.FAIL, e)
-
-    else:
-        for build_target in execution.build_targets:
-            _run_build_target(sut_node, tg_node, build_target, execution, execution_result)
-
-    finally:
-        try:
-            sut_node.tear_down_execution()
-            execution_result.update_teardown(Result.PASS)
-        except Exception as e:
-            dts_logger.exception("Execution teardown failed.")
-            execution_result.update_teardown(Result.FAIL, e)
-
-
-def _run_build_target(
-    sut_node: SutNode,
-    tg_node: TGNode,
-    build_target: BuildTargetConfiguration,
-    execution: ExecutionConfiguration,
-    execution_result: ExecutionResult,
-) -> None:
-    """Run the given build target.
-
-    This involves running the build target setup as well as running all test suites
-    in the given execution the build target is defined in.
-    After that, build target teardown is run.
-
-    Args:
-        sut_node: The execution's SUT node.
-        tg_node: The execution's TG node.
-        build_target: A build target's test run configuration.
-        execution: The build target's execution's test run configuration.
-        execution_result: The execution level result object associated with the execution.
-    """
-    dts_logger.info(f"Running build target '{build_target.name}'.")
-    build_target_result = execution_result.add_build_target(build_target)
-
-    try:
-        sut_node.set_up_build_target(build_target)
-        result.dpdk_version = sut_node.dpdk_version
-        build_target_result.add_build_target_info(sut_node.get_build_target_info())
-        build_target_result.update_setup(Result.PASS)
-    except Exception as e:
-        dts_logger.exception("Build target setup failed.")
-        build_target_result.update_setup(Result.FAIL, e)
-
-    else:
-        _run_all_suites(sut_node, tg_node, execution, build_target_result)
-
-    finally:
-        try:
-            sut_node.tear_down_build_target()
-            build_target_result.update_teardown(Result.PASS)
-        except Exception as e:
-            dts_logger.exception("Build target teardown failed.")
-            build_target_result.update_teardown(Result.FAIL, e)
-
-
-def _run_all_suites(
-    sut_node: SutNode,
-    tg_node: TGNode,
-    execution: ExecutionConfiguration,
-    build_target_result: BuildTargetResult,
-) -> None:
-    """Run the execution's (possibly a subset) test suites using the current build target.
-
-    The function assumes the build target we're testing has already been built on the SUT node.
-    The current build target thus corresponds to the current DPDK build present on the SUT node.
-
-    If a blocking test suite (such as the smoke test suite) fails, the rest of the test suites
-    in the current build target won't be executed.
-
-    Args:
-        sut_node: The execution's SUT node.
-        tg_node: The execution's TG node.
-        execution: The execution's test run configuration associated with the current build target.
-        build_target_result: The build target level result object associated
-            with the current build target.
-    """
-    end_build_target = False
-    if not execution.skip_smoke_tests:
-        execution.test_suites[:0] = [TestSuiteConfig.from_dict("smoke_tests")]
-    for test_suite_config in execution.test_suites:
-        try:
-            _run_single_suite(sut_node, tg_node, execution, build_target_result, test_suite_config)
-        except BlockingTestSuiteError as e:
-            dts_logger.exception(
-                f"An error occurred within {test_suite_config.test_suite}. Skipping build target."
-            )
-            result.add_error(e)
-            end_build_target = True
-        # if a blocking test failed and we need to bail out of suite executions
-        if end_build_target:
-            break
-
-
-def _run_single_suite(
-    sut_node: SutNode,
-    tg_node: TGNode,
-    execution: ExecutionConfiguration,
-    build_target_result: BuildTargetResult,
-    test_suite_config: TestSuiteConfig,
-) -> None:
-    """Run all test suite in a single test suite module.
-
-    The function assumes the build target we're testing has already been built on the SUT node.
-    The current build target thus corresponds to the current DPDK build present on the SUT node.
-
-    Args:
-        sut_node: The execution's SUT node.
-        tg_node: The execution's TG node.
-        execution: The execution's test run configuration associated with the current build target.
-        build_target_result: The build target level result object associated
-            with the current build target.
-        test_suite_config: Test suite test run configuration specifying the test suite module
-            and possibly a subset of test cases of test suites in that module.
-
-    Raises:
-        BlockingTestSuiteError: If a blocking test suite fails.
-    """
-    try:
-        full_suite_path = f"tests.TestSuite_{test_suite_config.test_suite}"
-        test_suite_classes = get_test_suites(full_suite_path)
-        suites_str = ", ".join((x.__name__ for x in test_suite_classes))
-        dts_logger.debug(f"Found test suites '{suites_str}' in '{full_suite_path}'.")
-    except Exception as e:
-        dts_logger.exception("An error occurred when searching for test suites.")
-        result.update_setup(Result.ERROR, e)
-
-    else:
-        for test_suite_class in test_suite_classes:
-            test_suite = test_suite_class(
-                sut_node,
-                tg_node,
-                test_suite_config.test_cases,
-                execution.func,
-                build_target_result,
-            )
-            test_suite.run()
-
-
-def _exit_dts() -> None:
-    """Process all errors and exit with the proper exit code."""
-    result.process()
-
-    if dts_logger:
-        dts_logger.info("DTS execution has ended.")
-    sys.exit(result.get_return_code())
diff --git a/dts/framework/runner.py b/dts/framework/runner.py
new file mode 100644
index 0000000000..acc1c4d6db
--- /dev/null
+++ b/dts/framework/runner.py
@@ -0,0 +1,333 @@
+# SPDX-License-Identifier: BSD-3-Clause
+# Copyright(c) 2010-2019 Intel Corporation
+# Copyright(c) 2022-2023 PANTHEON.tech s.r.o.
+# Copyright(c) 2022-2023 University of New Hampshire
+
+"""Test suite runner module.
+
+The module is responsible for running DTS in a series of stages:
+
+    #. Execution stage,
+    #. Build target stage,
+    #. Test suite stage,
+    #. Test case stage.
+
+The execution and build target stages set up the environment before running test suites.
+The test suite stage sets up steps common to all test cases
+and the test case stage runs test cases individually.
+"""
+
+import logging
+import sys
+
+from .config import (
+    BuildTargetConfiguration,
+    ExecutionConfiguration,
+    TestSuiteConfig,
+    load_config,
+)
+from .exception import BlockingTestSuiteError
+from .logger import DTSLOG, getLogger
+from .test_result import BuildTargetResult, DTSResult, ExecutionResult, Result
+from .test_suite import get_test_suites
+from .testbed_model import SutNode, TGNode
+
+
+class DTSRunner:
+    r"""Test suite runner class.
+
+    The class is responsible for running tests on testbeds defined in the test run configuration.
+    Each setup or teardown of each stage is recorded in a :class:`~framework.test_result.DTSResult`
+    or one of its subclasses. The test case results are also recorded.
+
+    If an error occurs, the current stage is aborted, the error is recorded and the run continues in
+    the next iteration of the same stage. The return code is the highest `severity` of all
+    :class:`~.framework.exception.DTSError`\s.
+
+    Example:
+        An error occurs in a build target setup. The current build target is aborted and the run
+        continues with the next build target. If the errored build target was the last one in the
+        given execution, the next execution begins.
+    """
+
+    _logger: DTSLOG
+    _result: DTSResult
+
+    def __init__(self):
+        """Initialize the instance with logger and result."""
+        self._logger = getLogger("DTSRunner")
+        self._result = DTSResult(self._logger)
+
+    def run(self):
+        """Run all build targets in all executions from the test run configuration.
+
+        Before running test suites, executions and build targets are first set up.
+        The executions and build targets defined in the test run configuration are iterated over.
+        The executions define which tests to run and where to run them and build targets define
+        the DPDK build setup.
+
+        The tests suites are set up for each execution/build target tuple and each discovered
+        test case within the test suite is set up, executed and torn down. After all test cases
+        have been executed, the test suite is torn down and the next build target will be tested.
+
+        All the nested steps look like this:
+
+            #. Execution setup
+
+                #. Build target setup
+
+                    #. Test suite setup
+
+                        #. Test case setup
+                        #. Test case logic
+                        #. Test case teardown
+
+                    #. Test suite teardown
+
+                #. Build target teardown
+
+            #. Execution teardown
+
+        The test cases are filtered according to the specification in the test run configuration and
+        the :option:`--test-cases` command line argument or
+        the :envvar:`DTS_TESTCASES` environment variable.
+        """
+        sut_nodes: dict[str, SutNode] = {}
+        tg_nodes: dict[str, TGNode] = {}
+        try:
+            # check the python version of the server that runs dts
+            self._check_dts_python_version()
+
+            # for all Execution sections
+            for execution in load_config().executions:
+                sut_node = sut_nodes.get(execution.system_under_test_node.name)
+                tg_node = tg_nodes.get(execution.traffic_generator_node.name)
+
+                try:
+                    if not sut_node:
+                        sut_node = SutNode(execution.system_under_test_node)
+                        sut_nodes[sut_node.name] = sut_node
+                    if not tg_node:
+                        tg_node = TGNode(execution.traffic_generator_node)
+                        tg_nodes[tg_node.name] = tg_node
+                    self._result.update_setup(Result.PASS)
+                except Exception as e:
+                    failed_node = execution.system_under_test_node.name
+                    if sut_node:
+                        failed_node = execution.traffic_generator_node.name
+                    self._logger.exception(f"The Creation of node {failed_node} failed.")
+                    self._result.update_setup(Result.FAIL, e)
+
+                else:
+                    self._run_execution(sut_node, tg_node, execution)
+
+        except Exception as e:
+            self._logger.exception("An unexpected error has occurred.")
+            self._result.add_error(e)
+            raise
+
+        finally:
+            try:
+                for node in (sut_nodes | tg_nodes).values():
+                    node.close()
+                self._result.update_teardown(Result.PASS)
+            except Exception as e:
+                self._logger.exception("The final cleanup of nodes failed.")
+                self._result.update_teardown(Result.ERROR, e)
+
+        # we need to put the sys.exit call outside the finally clause to make sure
+        # that unexpected exceptions will propagate
+        # in that case, the error that should be reported is the uncaught exception as
+        # that is a severe error originating from the framework
+        # at that point, we'll only have partial results which could be impacted by the
+        # error causing the uncaught exception, making them uninterpretable
+        self._exit_dts()
+
+    def _check_dts_python_version(self) -> None:
+        """Check the required Python version - v3.10."""
+        if sys.version_info.major < 3 or (
+            sys.version_info.major == 3 and sys.version_info.minor < 10
+        ):
+            self._logger.warning(
+                "DTS execution node's python version is lower than Python 3.10, "
+                "is deprecated and will not work in future releases."
+            )
+            self._logger.warning("Please use Python >= 3.10 instead.")
+
+    def _run_execution(
+        self,
+        sut_node: SutNode,
+        tg_node: TGNode,
+        execution: ExecutionConfiguration,
+    ) -> None:
+        """Run the given execution.
+
+        This involves running the execution setup as well as running all build targets
+        in the given execution. After that, execution teardown is run.
+
+        Args:
+            sut_node: The execution's SUT node.
+            tg_node: The execution's TG node.
+            execution: An execution's test run configuration.
+        """
+        self._logger.info(f"Running execution with SUT '{execution.system_under_test_node.name}'.")
+        execution_result = self._result.add_execution(sut_node.config)
+        execution_result.add_sut_info(sut_node.node_info)
+
+        try:
+            sut_node.set_up_execution(execution)
+            execution_result.update_setup(Result.PASS)
+        except Exception as e:
+            self._logger.exception("Execution setup failed.")
+            execution_result.update_setup(Result.FAIL, e)
+
+        else:
+            for build_target in execution.build_targets:
+                self._run_build_target(sut_node, tg_node, build_target, execution, execution_result)
+
+        finally:
+            try:
+                sut_node.tear_down_execution()
+                execution_result.update_teardown(Result.PASS)
+            except Exception as e:
+                self._logger.exception("Execution teardown failed.")
+                execution_result.update_teardown(Result.FAIL, e)
+
+    def _run_build_target(
+        self,
+        sut_node: SutNode,
+        tg_node: TGNode,
+        build_target: BuildTargetConfiguration,
+        execution: ExecutionConfiguration,
+        execution_result: ExecutionResult,
+    ) -> None:
+        """Run the given build target.
+
+        This involves running the build target setup as well as running all test suites
+        of the build target's execution.
+        After that, build target teardown is run.
+
+        Args:
+            sut_node: The execution's sut node.
+            tg_node: The execution's tg node.
+            build_target: A build target's test run configuration.
+            execution: The build target's execution's test run configuration.
+            execution_result: The execution level result object associated with the execution.
+        """
+        self._logger.info(f"Running build target '{build_target.name}'.")
+        build_target_result = execution_result.add_build_target(build_target)
+
+        try:
+            sut_node.set_up_build_target(build_target)
+            self._result.dpdk_version = sut_node.dpdk_version
+            build_target_result.add_build_target_info(sut_node.get_build_target_info())
+            build_target_result.update_setup(Result.PASS)
+        except Exception as e:
+            self._logger.exception("Build target setup failed.")
+            build_target_result.update_setup(Result.FAIL, e)
+
+        else:
+            self._run_all_suites(sut_node, tg_node, execution, build_target_result)
+
+        finally:
+            try:
+                sut_node.tear_down_build_target()
+                build_target_result.update_teardown(Result.PASS)
+            except Exception as e:
+                self._logger.exception("Build target teardown failed.")
+                build_target_result.update_teardown(Result.FAIL, e)
+
+    def _run_all_suites(
+        self,
+        sut_node: SutNode,
+        tg_node: TGNode,
+        execution: ExecutionConfiguration,
+        build_target_result: BuildTargetResult,
+    ) -> None:
+        """Run the execution's (possibly a subset of) test suites using the current build target.
+
+        The method assumes the build target we're testing has already been built on the SUT node.
+        The current build target thus corresponds to the current DPDK build present on the SUT node.
+
+        Args:
+            sut_node: The execution's SUT node.
+            tg_node: The execution's TG node.
+            execution: The execution's test run configuration associated
+                with the current build target.
+            build_target_result: The build target level result object associated
+                with the current build target.
+        """
+        end_build_target = False
+        if not execution.skip_smoke_tests:
+            execution.test_suites[:0] = [TestSuiteConfig.from_dict("smoke_tests")]
+        for test_suite_config in execution.test_suites:
+            try:
+                self._run_single_suite(
+                    sut_node, tg_node, execution, build_target_result, test_suite_config
+                )
+            except BlockingTestSuiteError as e:
+                self._logger.exception(
+                    f"An error occurred within {test_suite_config.test_suite}. "
+                    "Skipping build target..."
+                )
+                self._result.add_error(e)
+                end_build_target = True
+            # if a blocking test failed and we need to bail out of suite executions
+            if end_build_target:
+                break
+
+    def _run_single_suite(
+        self,
+        sut_node: SutNode,
+        tg_node: TGNode,
+        execution: ExecutionConfiguration,
+        build_target_result: BuildTargetResult,
+        test_suite_config: TestSuiteConfig,
+    ) -> None:
+        """Run all test suites in a single test suite module.
+
+        The method assumes the build target we're testing has already been built on the SUT node.
+        The current build target thus corresponds to the current DPDK build present on the SUT node.
+
+        Args:
+            sut_node: The execution's SUT node.
+            tg_node: The execution's TG node.
+            execution: The execution's test run configuration associated
+                with the current build target.
+            build_target_result: The build target level result object associated
+                with the current build target.
+            test_suite_config: Test suite test run configuration specifying the test suite module
+                and possibly a subset of test cases of test suites in that module.
+
+        Raises:
+            BlockingTestSuiteError: If a blocking test suite fails.
+        """
+        try:
+            full_suite_path = f"tests.TestSuite_{test_suite_config.test_suite}"
+            test_suite_classes = get_test_suites(full_suite_path)
+            suites_str = ", ".join((x.__name__ for x in test_suite_classes))
+            self._logger.debug(f"Found test suites '{suites_str}' in '{full_suite_path}'.")
+        except Exception as e:
+            self._logger.exception("An error occurred when searching for test suites.")
+            self._result.update_setup(Result.ERROR, e)
+
+        else:
+            for test_suite_class in test_suite_classes:
+                test_suite = test_suite_class(
+                    sut_node,
+                    tg_node,
+                    test_suite_config.test_cases,
+                    execution.func,
+                    build_target_result,
+                )
+                test_suite.run()
+
+    def _exit_dts(self) -> None:
+        """Process all errors and exit with the proper exit code."""
+        self._result.process()
+
+        if self._logger:
+            self._logger.info("DTS execution has ended.")
+
+        logging.shutdown()
+        sys.exit(self._result.get_return_code())
diff --git a/dts/main.py b/dts/main.py
index f703615d11..1ffe8ff81f 100755
--- a/dts/main.py
+++ b/dts/main.py
@@ -21,9 +21,11 @@ def main() -> None:
     be modified before the settings module is imported anywhere else in the framework.
     """
     settings.SETTINGS = settings.get_settings()
-    from framework import dts
 
-    dts.run_all()
+    from framework.runner import DTSRunner
+
+    dts = DTSRunner()
+    dts.run()
 
 
 # Main program begins here
-- 
2.34.1


^ permalink raw reply	[flat|nested] 28+ messages in thread

* [PATCH v2 2/7] dts: move test suite execution logic to DTSRunner
  2024-02-06 14:57 ` [PATCH v2 0/7] " Juraj Linkeš
  2024-02-06 14:57   ` [PATCH v2 1/7] dts: convert dts.py methods to class Juraj Linkeš
@ 2024-02-06 14:57   ` Juraj Linkeš
  2024-02-06 14:57   ` [PATCH v2 3/7] dts: filter test suites in executions Juraj Linkeš
                     ` (4 subsequent siblings)
  6 siblings, 0 replies; 28+ messages in thread
From: Juraj Linkeš @ 2024-02-06 14:57 UTC (permalink / raw)
  To: thomas, Honnappa.Nagarahalli, jspewock, probb, paul.szczepanek,
	Luca.Vizzarro
  Cc: dev, Juraj Linkeš

Move the code responsible for running the test suite from the
TestSuite class to the DTSRunner class. This restructuring decision
was made to consolidate and unify the related logic into a single unit.

Signed-off-by: Juraj Linkeš <juraj.linkes@pantheon.tech>
---
 dts/framework/runner.py     | 175 ++++++++++++++++++++++++++++++++----
 dts/framework/test_suite.py | 152 ++-----------------------------
 2 files changed, 169 insertions(+), 158 deletions(-)

diff --git a/dts/framework/runner.py b/dts/framework/runner.py
index acc1c4d6db..933685d638 100644
--- a/dts/framework/runner.py
+++ b/dts/framework/runner.py
@@ -19,6 +19,7 @@
 
 import logging
 import sys
+from types import MethodType
 
 from .config import (
     BuildTargetConfiguration,
@@ -26,10 +27,18 @@
     TestSuiteConfig,
     load_config,
 )
-from .exception import BlockingTestSuiteError
+from .exception import BlockingTestSuiteError, SSHTimeoutError, TestCaseVerifyError
 from .logger import DTSLOG, getLogger
-from .test_result import BuildTargetResult, DTSResult, ExecutionResult, Result
-from .test_suite import get_test_suites
+from .settings import SETTINGS
+from .test_result import (
+    BuildTargetResult,
+    DTSResult,
+    ExecutionResult,
+    Result,
+    TestCaseResult,
+    TestSuiteResult,
+)
+from .test_suite import TestSuite, get_test_suites
 from .testbed_model import SutNode, TGNode
 
 
@@ -227,7 +236,7 @@ def _run_build_target(
             build_target_result.update_setup(Result.FAIL, e)
 
         else:
-            self._run_all_suites(sut_node, tg_node, execution, build_target_result)
+            self._run_test_suites(sut_node, tg_node, execution, build_target_result)
 
         finally:
             try:
@@ -237,7 +246,7 @@ def _run_build_target(
                 self._logger.exception("Build target teardown failed.")
                 build_target_result.update_teardown(Result.FAIL, e)
 
-    def _run_all_suites(
+    def _run_test_suites(
         self,
         sut_node: SutNode,
         tg_node: TGNode,
@@ -249,6 +258,9 @@ def _run_all_suites(
         The method assumes the build target we're testing has already been built on the SUT node.
         The current build target thus corresponds to the current DPDK build present on the SUT node.
 
+        If a blocking test suite (such as the smoke test suite) fails, the rest of the test suites
+        in the current build target won't be executed.
+
         Args:
             sut_node: The execution's SUT node.
             tg_node: The execution's TG node.
@@ -262,7 +274,7 @@ def _run_all_suites(
             execution.test_suites[:0] = [TestSuiteConfig.from_dict("smoke_tests")]
         for test_suite_config in execution.test_suites:
             try:
-                self._run_single_suite(
+                self._run_test_suite_module(
                     sut_node, tg_node, execution, build_target_result, test_suite_config
                 )
             except BlockingTestSuiteError as e:
@@ -276,7 +288,7 @@ def _run_all_suites(
             if end_build_target:
                 break
 
-    def _run_single_suite(
+    def _run_test_suite_module(
         self,
         sut_node: SutNode,
         tg_node: TGNode,
@@ -284,11 +296,18 @@ def _run_single_suite(
         build_target_result: BuildTargetResult,
         test_suite_config: TestSuiteConfig,
     ) -> None:
-        """Run all test suites in a single test suite module.
+        """Set up, execute and tear down all test suites in a single test suite module.
 
         The method assumes the build target we're testing has already been built on the SUT node.
         The current build target thus corresponds to the current DPDK build present on the SUT node.
 
+        Test suite execution consists of running the discovered test cases.
+        A test case run consists of setup, execution and teardown of said test case.
+
+        Record the setup and the teardown and handle failures.
+
+        The test cases to execute are discovered when creating the :class:`TestSuite` object.
+
         Args:
             sut_node: The execution's SUT node.
             tg_node: The execution's TG node.
@@ -313,14 +332,140 @@ def _run_single_suite(
 
         else:
             for test_suite_class in test_suite_classes:
-                test_suite = test_suite_class(
-                    sut_node,
-                    tg_node,
-                    test_suite_config.test_cases,
-                    execution.func,
-                    build_target_result,
+                test_suite = test_suite_class(sut_node, tg_node, test_suite_config.test_cases)
+
+                test_suite_name = test_suite.__class__.__name__
+                test_suite_result = build_target_result.add_test_suite(test_suite_name)
+                try:
+                    self._logger.info(f"Starting test suite setup: {test_suite_name}")
+                    test_suite.set_up_suite()
+                    test_suite_result.update_setup(Result.PASS)
+                    self._logger.info(f"Test suite setup successful: {test_suite_name}")
+                except Exception as e:
+                    self._logger.exception(f"Test suite setup ERROR: {test_suite_name}")
+                    test_suite_result.update_setup(Result.ERROR, e)
+
+                else:
+                    self._execute_test_suite(execution.func, test_suite, test_suite_result)
+
+                finally:
+                    try:
+                        test_suite.tear_down_suite()
+                        sut_node.kill_cleanup_dpdk_apps()
+                        test_suite_result.update_teardown(Result.PASS)
+                    except Exception as e:
+                        self._logger.exception(f"Test suite teardown ERROR: {test_suite_name}")
+                        self._logger.warning(
+                            f"Test suite '{test_suite_name}' teardown failed, "
+                            f"the next test suite may be affected."
+                        )
+                        test_suite_result.update_setup(Result.ERROR, e)
+                    if len(test_suite_result.get_errors()) > 0 and test_suite.is_blocking:
+                        raise BlockingTestSuiteError(test_suite_name)
+
+    def _execute_test_suite(
+        self, func: bool, test_suite: TestSuite, test_suite_result: TestSuiteResult
+    ) -> None:
+        """Execute all discovered test cases in `test_suite`.
+
+        If the :option:`--re-run` command line argument or the :envvar:`DTS_RERUN` environment
+        variable is set, in case of a test case failure, the test case will be executed again
+        until it passes or it fails that many times in addition of the first failure.
+
+        Args:
+            func: Whether to execute functional test cases.
+            test_suite: The test suite object.
+            test_suite_result: The test suite level result object associated
+                with the current test suite.
+        """
+        if func:
+            for test_case_method in test_suite._get_functional_test_cases():
+                test_case_name = test_case_method.__name__
+                test_case_result = test_suite_result.add_test_case(test_case_name)
+                all_attempts = SETTINGS.re_run + 1
+                attempt_nr = 1
+                self._run_test_case(test_suite, test_case_method, test_case_result)
+                while not test_case_result and attempt_nr < all_attempts:
+                    attempt_nr += 1
+                    self._logger.info(
+                        f"Re-running FAILED test case '{test_case_name}'. "
+                        f"Attempt number {attempt_nr} out of {all_attempts}."
+                    )
+                    self._run_test_case(test_suite, test_case_method, test_case_result)
+
+    def _run_test_case(
+        self,
+        test_suite: TestSuite,
+        test_case_method: MethodType,
+        test_case_result: TestCaseResult,
+    ) -> None:
+        """Setup, execute and teardown a test case in `test_suite`.
+
+        Record the result of the setup and the teardown and handle failures.
+
+        Args:
+            test_suite: The test suite object.
+            test_case_method: The test case method.
+            test_case_result: The test case level result object associated
+                with the current test case.
+        """
+        test_case_name = test_case_method.__name__
+
+        try:
+            # run set_up function for each case
+            test_suite.set_up_test_case()
+            test_case_result.update_setup(Result.PASS)
+        except SSHTimeoutError as e:
+            self._logger.exception(f"Test case setup FAILED: {test_case_name}")
+            test_case_result.update_setup(Result.FAIL, e)
+        except Exception as e:
+            self._logger.exception(f"Test case setup ERROR: {test_case_name}")
+            test_case_result.update_setup(Result.ERROR, e)
+
+        else:
+            # run test case if setup was successful
+            self._execute_test_case(test_case_method, test_case_result)
+
+        finally:
+            try:
+                test_suite.tear_down_test_case()
+                test_case_result.update_teardown(Result.PASS)
+            except Exception as e:
+                self._logger.exception(f"Test case teardown ERROR: {test_case_name}")
+                self._logger.warning(
+                    f"Test case '{test_case_name}' teardown failed, "
+                    f"the next test case may be affected."
                 )
-                test_suite.run()
+                test_case_result.update_teardown(Result.ERROR, e)
+                test_case_result.update(Result.ERROR)
+
+    def _execute_test_case(
+        self, test_case_method: MethodType, test_case_result: TestCaseResult
+    ) -> None:
+        """Execute one test case, record the result and handle failures.
+
+        Args:
+            test_case_method: The test case method.
+            test_case_result: The test case level result object associated
+                with the current test case.
+        """
+        test_case_name = test_case_method.__name__
+        try:
+            self._logger.info(f"Starting test case execution: {test_case_name}")
+            test_case_method()
+            test_case_result.update(Result.PASS)
+            self._logger.info(f"Test case execution PASSED: {test_case_name}")
+
+        except TestCaseVerifyError as e:
+            self._logger.exception(f"Test case execution FAILED: {test_case_name}")
+            test_case_result.update(Result.FAIL, e)
+        except Exception as e:
+            self._logger.exception(f"Test case execution ERROR: {test_case_name}")
+            test_case_result.update(Result.ERROR, e)
+        except KeyboardInterrupt:
+            self._logger.error(f"Test case execution INTERRUPTED by user: {test_case_name}")
+            test_case_result.update(Result.SKIP)
+            raise KeyboardInterrupt("Stop DTS")
 
     def _exit_dts(self) -> None:
         """Process all errors and exit with the proper exit code."""
diff --git a/dts/framework/test_suite.py b/dts/framework/test_suite.py
index dfb391ffbd..b02fd36147 100644
--- a/dts/framework/test_suite.py
+++ b/dts/framework/test_suite.py
@@ -8,7 +8,6 @@
 must be extended by subclasses which add test cases. The :class:`TestSuite` contains the basics
 needed by subclasses:
 
-    * Test suite and test case execution flow,
     * Testbed (SUT, TG) configuration,
     * Packet sending and verification,
     * Test case verification.
@@ -28,27 +27,22 @@
 from scapy.layers.l2 import Ether  # type: ignore[import]
 from scapy.packet import Packet, Padding  # type: ignore[import]
 
-from .exception import (
-    BlockingTestSuiteError,
-    ConfigurationError,
-    SSHTimeoutError,
-    TestCaseVerifyError,
-)
+from .exception import ConfigurationError, TestCaseVerifyError
 from .logger import DTSLOG, getLogger
 from .settings import SETTINGS
-from .test_result import BuildTargetResult, Result, TestCaseResult, TestSuiteResult
 from .testbed_model import Port, PortLink, SutNode, TGNode
 from .utils import get_packet_summaries
 
 
 class TestSuite(object):
-    """The base class with methods for handling the basic flow of a test suite.
+    """The base class with building blocks needed by most test cases.
 
         * Test case filtering and collection,
-        * Test suite setup/cleanup,
-        * Test setup/cleanup,
-        * Test case execution,
-        * Error handling and results storage.
+        * Test suite setup/cleanup methods to override,
+        * Test case setup/cleanup methods to override,
+        * Test case verification,
+        * Testbed configuration,
+        * Traffic sending and verification.
 
     Test cases are implemented by subclasses. Test cases are all methods starting with ``test_``,
     further divided into performance test cases (starting with ``test_perf_``)
@@ -60,10 +54,6 @@ class TestSuite(object):
     The union of both lists will be used. Any unknown test cases from the latter lists
     will be silently ignored.
 
-    If the :option:`--re-run` command line argument or the :envvar:`DTS_RERUN` environment variable
-    is set, in case of a test case failure, the test case will be executed again until it passes
-    or it fails that many times in addition of the first failure.
-
     The methods named ``[set_up|tear_down]_[suite|test_case]`` should be overridden in subclasses
     if the appropriate test suite/test case fixtures are needed.
 
@@ -82,8 +72,6 @@ class TestSuite(object):
     is_blocking: ClassVar[bool] = False
     _logger: DTSLOG
     _test_cases_to_run: list[str]
-    _func: bool
-    _result: TestSuiteResult
     _port_links: list[PortLink]
     _sut_port_ingress: Port
     _sut_port_egress: Port
@@ -99,30 +87,23 @@ def __init__(
         sut_node: SutNode,
         tg_node: TGNode,
         test_cases: list[str],
-        func: bool,
-        build_target_result: BuildTargetResult,
     ):
         """Initialize the test suite testbed information and basic configuration.
 
-        Process what test cases to run, create the associated
-        :class:`~.test_result.TestSuiteResult`, find links between ports
-        and set up default IP addresses to be used when configuring them.
+        Process what test cases to run, find links between ports and set up
+        default IP addresses to be used when configuring them.
 
         Args:
             sut_node: The SUT node where the test suite will run.
             tg_node: The TG node where the test suite will run.
             test_cases: The list of test cases to execute.
                 If empty, all test cases will be executed.
-            func: Whether to run functional tests.
-            build_target_result: The build target result this test suite is run in.
         """
         self.sut_node = sut_node
         self.tg_node = tg_node
         self._logger = getLogger(self.__class__.__name__)
         self._test_cases_to_run = test_cases
         self._test_cases_to_run.extend(SETTINGS.test_cases)
-        self._func = func
-        self._result = build_target_result.add_test_suite(self.__class__.__name__)
         self._port_links = []
         self._process_links()
         self._sut_port_ingress, self._tg_port_egress = (
@@ -384,62 +365,6 @@ def _verify_l3_packet(self, received_packet: IP, expected_packet: IP) -> bool:
             return False
         return True
 
-    def run(self) -> None:
-        """Set up, execute and tear down the whole suite.
-
-        Test suite execution consists of running all test cases scheduled to be executed.
-        A test case run consists of setup, execution and teardown of said test case.
-
-        Record the setup and the teardown and handle failures.
-
-        The list of scheduled test cases is constructed when creating the :class:`TestSuite` object.
-        """
-        test_suite_name = self.__class__.__name__
-
-        try:
-            self._logger.info(f"Starting test suite setup: {test_suite_name}")
-            self.set_up_suite()
-            self._result.update_setup(Result.PASS)
-            self._logger.info(f"Test suite setup successful: {test_suite_name}")
-        except Exception as e:
-            self._logger.exception(f"Test suite setup ERROR: {test_suite_name}")
-            self._result.update_setup(Result.ERROR, e)
-
-        else:
-            self._execute_test_suite()
-
-        finally:
-            try:
-                self.tear_down_suite()
-                self.sut_node.kill_cleanup_dpdk_apps()
-                self._result.update_teardown(Result.PASS)
-            except Exception as e:
-                self._logger.exception(f"Test suite teardown ERROR: {test_suite_name}")
-                self._logger.warning(
-                    f"Test suite '{test_suite_name}' teardown failed, "
-                    f"the next test suite may be affected."
-                )
-                self._result.update_setup(Result.ERROR, e)
-            if len(self._result.get_errors()) > 0 and self.is_blocking:
-                raise BlockingTestSuiteError(test_suite_name)
-
-    def _execute_test_suite(self) -> None:
-        """Execute all test cases scheduled to be executed in this suite."""
-        if self._func:
-            for test_case_method in self._get_functional_test_cases():
-                test_case_name = test_case_method.__name__
-                test_case_result = self._result.add_test_case(test_case_name)
-                all_attempts = SETTINGS.re_run + 1
-                attempt_nr = 1
-                self._run_test_case(test_case_method, test_case_result)
-                while not test_case_result and attempt_nr < all_attempts:
-                    attempt_nr += 1
-                    self._logger.info(
-                        f"Re-running FAILED test case '{test_case_name}'. "
-                        f"Attempt number {attempt_nr} out of {all_attempts}."
-                    )
-                    self._run_test_case(test_case_method, test_case_result)
-
     def _get_functional_test_cases(self) -> list[MethodType]:
         """Get all functional test cases defined in this TestSuite.
 
@@ -471,65 +396,6 @@ def _should_be_executed(self, test_case_name: str, test_case_regex: str) -> bool
 
         return match
 
-    def _run_test_case(
-        self, test_case_method: MethodType, test_case_result: TestCaseResult
-    ) -> None:
-        """Setup, execute and teardown a test case in this suite.
-
-        Record the result of the setup and the teardown and handle failures.
-        """
-        test_case_name = test_case_method.__name__
-
-        try:
-            # run set_up function for each case
-            self.set_up_test_case()
-            test_case_result.update_setup(Result.PASS)
-        except SSHTimeoutError as e:
-            self._logger.exception(f"Test case setup FAILED: {test_case_name}")
-            test_case_result.update_setup(Result.FAIL, e)
-        except Exception as e:
-            self._logger.exception(f"Test case setup ERROR: {test_case_name}")
-            test_case_result.update_setup(Result.ERROR, e)
-
-        else:
-            # run test case if setup was successful
-            self._execute_test_case(test_case_method, test_case_result)
-
-        finally:
-            try:
-                self.tear_down_test_case()
-                test_case_result.update_teardown(Result.PASS)
-            except Exception as e:
-                self._logger.exception(f"Test case teardown ERROR: {test_case_name}")
-                self._logger.warning(
-                    f"Test case '{test_case_name}' teardown failed, "
-                    f"the next test case may be affected."
-                )
-                test_case_result.update_teardown(Result.ERROR, e)
-                test_case_result.update(Result.ERROR)
-
-    def _execute_test_case(
-        self, test_case_method: MethodType, test_case_result: TestCaseResult
-    ) -> None:
-        """Execute one test case, record the result and handle failures."""
-        test_case_name = test_case_method.__name__
-        try:
-            self._logger.info(f"Starting test case execution: {test_case_name}")
-            test_case_method()
-            test_case_result.update(Result.PASS)
-            self._logger.info(f"Test case execution PASSED: {test_case_name}")
-
-        except TestCaseVerifyError as e:
-            self._logger.exception(f"Test case execution FAILED: {test_case_name}")
-            test_case_result.update(Result.FAIL, e)
-        except Exception as e:
-            self._logger.exception(f"Test case execution ERROR: {test_case_name}")
-            test_case_result.update(Result.ERROR, e)
-        except KeyboardInterrupt:
-            self._logger.error(f"Test case execution INTERRUPTED by user: {test_case_name}")
-            test_case_result.update(Result.SKIP)
-            raise KeyboardInterrupt("Stop DTS")
-
 
 def get_test_suites(testsuite_module_path: str) -> list[type[TestSuite]]:
     r"""Find all :class:`TestSuite`\s in a Python module.
-- 
2.34.1


^ permalink raw reply	[flat|nested] 28+ messages in thread

* [PATCH v2 3/7] dts: filter test suites in executions
  2024-02-06 14:57 ` [PATCH v2 0/7] " Juraj Linkeš
  2024-02-06 14:57   ` [PATCH v2 1/7] dts: convert dts.py methods to class Juraj Linkeš
  2024-02-06 14:57   ` [PATCH v2 2/7] dts: move test suite execution logic to DTSRunner Juraj Linkeš
@ 2024-02-06 14:57   ` Juraj Linkeš
  2024-02-12 16:44     ` Jeremy Spewock
  2024-02-06 14:57   ` [PATCH v2 4/7] dts: reorganize test result Juraj Linkeš
                     ` (3 subsequent siblings)
  6 siblings, 1 reply; 28+ messages in thread
From: Juraj Linkeš @ 2024-02-06 14:57 UTC (permalink / raw)
  To: thomas, Honnappa.Nagarahalli, jspewock, probb, paul.szczepanek,
	Luca.Vizzarro
  Cc: dev, Juraj Linkeš

We're currently filtering which test cases to run after some setup
steps, such as DPDK build, have already been taken. This prohibits us to
mark the test suites and cases that were supposed to be run as blocked
when an earlier setup fails, as that information is not available at
that time.

To remedy this, move the filtering to the beginning of each execution.
This is the first action taken in each execution and if we can't filter
the test cases, such as due to invalid inputs, we abort the whole
execution. No test suites nor cases will be marked as blocked as we
don't know which were supposed to be run.

On top of that, the filtering takes place in the TestSuite class, which
should only concern itself with test suite and test case logic, not the
processing behind the scenes. The logic has been moved to DTSRunner
which should do all the processing needed to run test suites.

The filtering itself introduces a few changes/assumptions which are more
sensible than before:
1. Assumption: There is just one TestSuite child class in each test
   suite module. This was an implicit assumption before as we couldn't
   specify the TestSuite classes in the test run configuration, just the
   modules. The name of the TestSuite child class starts with "Test" and
   then corresponds to the name of the module with CamelCase naming.
2. Unknown test cases specified both in the test run configuration and
   the environment variable/command line argument are no longer silently
   ignored. This is a quality of life improvement for users, as they
   could easily be not aware of the silent ignoration.

Also, a change in the code results in pycodestyle warning and error:
[E] E203 whitespace before ':'
[W] W503 line break before binary operator

These two are not PEP8 compliant, so they're disabled.

Signed-off-by: Juraj Linkeš <juraj.linkes@pantheon.tech>
---
 dts/framework/config/__init__.py           |  24 +-
 dts/framework/config/conf_yaml_schema.json |   2 +-
 dts/framework/runner.py                    | 426 +++++++++++++++------
 dts/framework/settings.py                  |   3 +-
 dts/framework/test_result.py               |  34 ++
 dts/framework/test_suite.py                |  85 +---
 dts/pyproject.toml                         |   3 +
 dts/tests/TestSuite_smoke_tests.py         |   2 +-
 8 files changed, 382 insertions(+), 197 deletions(-)

diff --git a/dts/framework/config/__init__.py b/dts/framework/config/__init__.py
index 62eded7f04..c6a93b3b89 100644
--- a/dts/framework/config/__init__.py
+++ b/dts/framework/config/__init__.py
@@ -36,7 +36,7 @@
 import json
 import os.path
 import pathlib
-from dataclasses import dataclass
+from dataclasses import dataclass, fields
 from enum import auto, unique
 from typing import Union
 
@@ -506,6 +506,28 @@ def from_dict(
             vdevs=vdevs,
         )
 
+    def copy_and_modify(self, **kwargs) -> "ExecutionConfiguration":
+        """Create a shallow copy with any of the fields modified.
+
+        The only new data are those passed to this method.
+        The rest are copied from the object's fields calling the method.
+
+        Args:
+            **kwargs: The names and types of keyword arguments are defined
+                by the fields of the :class:`ExecutionConfiguration` class.
+
+        Returns:
+            The copied and modified execution configuration.
+        """
+        new_config = {}
+        for field in fields(self):
+            if field.name in kwargs:
+                new_config[field.name] = kwargs[field.name]
+            else:
+                new_config[field.name] = getattr(self, field.name)
+
+        return ExecutionConfiguration(**new_config)
+
 
 @dataclass(slots=True, frozen=True)
 class Configuration:
diff --git a/dts/framework/config/conf_yaml_schema.json b/dts/framework/config/conf_yaml_schema.json
index 84e45fe3c2..051b079fe4 100644
--- a/dts/framework/config/conf_yaml_schema.json
+++ b/dts/framework/config/conf_yaml_schema.json
@@ -197,7 +197,7 @@
         },
         "cases": {
           "type": "array",
-          "description": "If specified, only this subset of test suite's test cases will be run. Unknown test cases will be silently ignored.",
+          "description": "If specified, only this subset of test suite's test cases will be run.",
           "items": {
             "type": "string"
           },
diff --git a/dts/framework/runner.py b/dts/framework/runner.py
index 933685d638..3e95cf9e26 100644
--- a/dts/framework/runner.py
+++ b/dts/framework/runner.py
@@ -17,17 +17,27 @@
 and the test case stage runs test cases individually.
 """
 
+import importlib
+import inspect
 import logging
+import re
 import sys
 from types import MethodType
+from typing import Iterable
 
 from .config import (
     BuildTargetConfiguration,
+    Configuration,
     ExecutionConfiguration,
     TestSuiteConfig,
     load_config,
 )
-from .exception import BlockingTestSuiteError, SSHTimeoutError, TestCaseVerifyError
+from .exception import (
+    BlockingTestSuiteError,
+    ConfigurationError,
+    SSHTimeoutError,
+    TestCaseVerifyError,
+)
 from .logger import DTSLOG, getLogger
 from .settings import SETTINGS
 from .test_result import (
@@ -37,8 +47,9 @@
     Result,
     TestCaseResult,
     TestSuiteResult,
+    TestSuiteWithCases,
 )
-from .test_suite import TestSuite, get_test_suites
+from .test_suite import TestSuite
 from .testbed_model import SutNode, TGNode
 
 
@@ -59,13 +70,23 @@ class DTSRunner:
         given execution, the next execution begins.
     """
 
+    _configuration: Configuration
     _logger: DTSLOG
     _result: DTSResult
+    _test_suite_class_prefix: str
+    _test_suite_module_prefix: str
+    _func_test_case_regex: str
+    _perf_test_case_regex: str
 
     def __init__(self):
-        """Initialize the instance with logger and result."""
+        """Initialize the instance with configuration, logger, result and string constants."""
+        self._configuration = load_config()
         self._logger = getLogger("DTSRunner")
         self._result = DTSResult(self._logger)
+        self._test_suite_class_prefix = "Test"
+        self._test_suite_module_prefix = "tests.TestSuite_"
+        self._func_test_case_regex = r"test_(?!perf_)"
+        self._perf_test_case_regex = r"test_perf_"
 
     def run(self):
         """Run all build targets in all executions from the test run configuration.
@@ -106,29 +127,28 @@ def run(self):
         try:
             # check the python version of the server that runs dts
             self._check_dts_python_version()
+            self._result.update_setup(Result.PASS)
 
             # for all Execution sections
-            for execution in load_config().executions:
-                sut_node = sut_nodes.get(execution.system_under_test_node.name)
-                tg_node = tg_nodes.get(execution.traffic_generator_node.name)
-
+            for execution in self._configuration.executions:
+                self._logger.info(
+                    f"Running execution with SUT '{execution.system_under_test_node.name}'."
+                )
+                execution_result = self._result.add_execution(execution.system_under_test_node)
                 try:
-                    if not sut_node:
-                        sut_node = SutNode(execution.system_under_test_node)
-                        sut_nodes[sut_node.name] = sut_node
-                    if not tg_node:
-                        tg_node = TGNode(execution.traffic_generator_node)
-                        tg_nodes[tg_node.name] = tg_node
-                    self._result.update_setup(Result.PASS)
+                    test_suites_with_cases = self._get_test_suites_with_cases(
+                        execution.test_suites, execution.func, execution.perf
+                    )
                 except Exception as e:
-                    failed_node = execution.system_under_test_node.name
-                    if sut_node:
-                        failed_node = execution.traffic_generator_node.name
-                    self._logger.exception(f"The Creation of node {failed_node} failed.")
-                    self._result.update_setup(Result.FAIL, e)
+                    self._logger.exception(
+                        f"Invalid test suite configuration found: " f"{execution.test_suites}."
+                    )
+                    execution_result.update_setup(Result.FAIL, e)
 
                 else:
-                    self._run_execution(sut_node, tg_node, execution)
+                    self._connect_nodes_and_run_execution(
+                        sut_nodes, tg_nodes, execution, execution_result, test_suites_with_cases
+                    )
 
         except Exception as e:
             self._logger.exception("An unexpected error has occurred.")
@@ -163,11 +183,204 @@ def _check_dts_python_version(self) -> None:
             )
             self._logger.warning("Please use Python >= 3.10 instead.")
 
+    def _get_test_suites_with_cases(
+        self,
+        test_suite_configs: list[TestSuiteConfig],
+        func: bool,
+        perf: bool,
+    ) -> list[TestSuiteWithCases]:
+        """Test suites with test cases discovery.
+
+        The test suites with test cases defined in the user configuration are discovered
+        and stored for future use so that we don't import the modules twice and so that
+        the list of test suites with test cases is available for recording right away.
+
+        Args:
+            test_suite_configs: Test suite configurations.
+            func: Whether to include functional test cases in the final list.
+            perf: Whether to include performance test cases in the final list.
+
+        Returns:
+            The discovered test suites, each with test cases.
+        """
+        test_suites_with_cases = []
+
+        for test_suite_config in test_suite_configs:
+            test_suite_class = self._get_test_suite_class(test_suite_config.test_suite)
+            test_cases = []
+            func_test_cases, perf_test_cases = self._filter_test_cases(
+                test_suite_class, set(test_suite_config.test_cases + SETTINGS.test_cases)
+            )
+            if func:
+                test_cases.extend(func_test_cases)
+            if perf:
+                test_cases.extend(perf_test_cases)
+
+            test_suites_with_cases.append(
+                TestSuiteWithCases(test_suite_class=test_suite_class, test_cases=test_cases)
+            )
+
+        return test_suites_with_cases
+
+    def _get_test_suite_class(self, test_suite_name: str) -> type[TestSuite]:
+        """Find the :class:`TestSuite` class with `test_suite_name` in the corresponding module.
+
+        The method assumes that the :class:`TestSuite` class starts
+        with `self._test_suite_class_prefix`,
+        continuing with `test_suite_name` with CamelCase convention.
+        It also assumes there's only one test suite in each module and the module name
+        is `test_suite_name` prefixed with `self._test_suite_module_prefix`.
+
+        The CamelCase convention is not tested, only lowercase strings are compared.
+
+        Args:
+            test_suite_name: The name of the test suite to find.
+
+        Returns:
+            The found test suite.
+
+        Raises:
+            ConfigurationError: If the corresponding module is not found or
+                a valid :class:`TestSuite` is not found in the module.
+        """
+
+        def is_test_suite(object) -> bool:
+            """Check whether `object` is a :class:`TestSuite`.
+
+            The `object` is a subclass of :class:`TestSuite`, but not :class:`TestSuite` itself.
+
+            Args:
+                object: The object to be checked.
+
+            Returns:
+                :data:`True` if `object` is a subclass of `TestSuite`.
+            """
+            try:
+                if issubclass(object, TestSuite) and object is not TestSuite:
+                    return True
+            except TypeError:
+                return False
+            return False
+
+        testsuite_module_path = f"{self._test_suite_module_prefix}{test_suite_name}"
+        try:
+            test_suite_module = importlib.import_module(testsuite_module_path)
+        except ModuleNotFoundError as e:
+            raise ConfigurationError(
+                f"Test suite module '{testsuite_module_path}' not found."
+            ) from e
+
+        lowercase_suite_name = test_suite_name.replace("_", "").lower()
+        for class_name, class_obj in inspect.getmembers(test_suite_module, is_test_suite):
+            if (
+                class_name.startswith(self._test_suite_class_prefix)
+                and lowercase_suite_name == class_name[len(self._test_suite_class_prefix) :].lower()
+            ):
+                return class_obj
+        raise ConfigurationError(
+            f"Couldn't find any valid test suites in {test_suite_module.__name__}."
+        )
+
+    def _filter_test_cases(
+        self, test_suite_class: type[TestSuite], test_cases_to_run: set[str]
+    ) -> tuple[list[MethodType], list[MethodType]]:
+        """Filter `test_cases_to_run` from `test_suite_class`.
+
+        There are two rounds of filtering if `test_cases_to_run` is not empty.
+        The first filters `test_cases_to_run` from all methods of `test_suite_class`.
+        Then the methods are separated into functional and performance test cases.
+        If a method doesn't match neither the functional nor performance name prefix, it's an error.
+
+        Args:
+            test_suite_class: The class of the test suite.
+            test_cases_to_run: Test case names to filter from `test_suite_class`.
+                If empty, return all matching test cases.
+
+        Returns:
+            A list of test case methods that should be executed.
+
+        Raises:
+            ConfigurationError: If a test case from `test_cases_to_run` is not found
+                or it doesn't match either the functional nor performance name prefix.
+        """
+        func_test_cases = []
+        perf_test_cases = []
+        name_method_tuples = inspect.getmembers(test_suite_class, inspect.isfunction)
+        if test_cases_to_run:
+            name_method_tuples = [
+                (name, method) for name, method in name_method_tuples if name in test_cases_to_run
+            ]
+            if len(name_method_tuples) < len(test_cases_to_run):
+                missing_test_cases = test_cases_to_run - {name for name, _ in name_method_tuples}
+                raise ConfigurationError(
+                    f"Test cases {missing_test_cases} not found among methods "
+                    f"of {test_suite_class.__name__}."
+                )
+
+        for test_case_name, test_case_method in name_method_tuples:
+            if re.match(self._func_test_case_regex, test_case_name):
+                func_test_cases.append(test_case_method)
+            elif re.match(self._perf_test_case_regex, test_case_name):
+                perf_test_cases.append(test_case_method)
+            elif test_cases_to_run:
+                raise ConfigurationError(
+                    f"Method '{test_case_name}' doesn't match neither "
+                    f"a functional nor a performance test case name."
+                )
+
+        return func_test_cases, perf_test_cases
+
+    def _connect_nodes_and_run_execution(
+        self,
+        sut_nodes: dict[str, SutNode],
+        tg_nodes: dict[str, TGNode],
+        execution: ExecutionConfiguration,
+        execution_result: ExecutionResult,
+        test_suites_with_cases: Iterable[TestSuiteWithCases],
+    ) -> None:
+        """Connect nodes, then continue to run the given execution.
+
+        Connect the :class:`SutNode` and the :class:`TGNode` of this `execution`.
+        If either has already been connected, it's going to be in either `sut_nodes` or `tg_nodes`,
+        respectively.
+        If not, connect and add the node to the respective `sut_nodes` or `tg_nodes` :class:`dict`.
+
+        Args:
+            sut_nodes: A dictionary storing connected/to be connected SUT nodes.
+            tg_nodes: A dictionary storing connected/to be connected TG nodes.
+            execution: An execution's test run configuration.
+            execution_result: The execution's result.
+            test_suites_with_cases: The test suites with test cases to run.
+        """
+        sut_node = sut_nodes.get(execution.system_under_test_node.name)
+        tg_node = tg_nodes.get(execution.traffic_generator_node.name)
+
+        try:
+            if not sut_node:
+                sut_node = SutNode(execution.system_under_test_node)
+                sut_nodes[sut_node.name] = sut_node
+            if not tg_node:
+                tg_node = TGNode(execution.traffic_generator_node)
+                tg_nodes[tg_node.name] = tg_node
+        except Exception as e:
+            failed_node = execution.system_under_test_node.name
+            if sut_node:
+                failed_node = execution.traffic_generator_node.name
+            self._logger.exception(f"The Creation of node {failed_node} failed.")
+            execution_result.update_setup(Result.FAIL, e)
+
+        else:
+            self._run_execution(
+                sut_node, tg_node, execution, execution_result, test_suites_with_cases
+            )
+
     def _run_execution(
         self,
         sut_node: SutNode,
         tg_node: TGNode,
         execution: ExecutionConfiguration,
+        execution_result: ExecutionResult,
+        test_suites_with_cases: Iterable[TestSuiteWithCases],
     ) -> None:
         """Run the given execution.
 
@@ -178,11 +391,11 @@ def _run_execution(
             sut_node: The execution's SUT node.
             tg_node: The execution's TG node.
             execution: An execution's test run configuration.
+            execution_result: The execution's result.
+            test_suites_with_cases: The test suites with test cases to run.
         """
         self._logger.info(f"Running execution with SUT '{execution.system_under_test_node.name}'.")
-        execution_result = self._result.add_execution(sut_node.config)
         execution_result.add_sut_info(sut_node.node_info)
-
         try:
             sut_node.set_up_execution(execution)
             execution_result.update_setup(Result.PASS)
@@ -192,7 +405,10 @@ def _run_execution(
 
         else:
             for build_target in execution.build_targets:
-                self._run_build_target(sut_node, tg_node, build_target, execution, execution_result)
+                build_target_result = execution_result.add_build_target(build_target)
+                self._run_build_target(
+                    sut_node, tg_node, build_target, build_target_result, test_suites_with_cases
+                )
 
         finally:
             try:
@@ -207,8 +423,8 @@ def _run_build_target(
         sut_node: SutNode,
         tg_node: TGNode,
         build_target: BuildTargetConfiguration,
-        execution: ExecutionConfiguration,
-        execution_result: ExecutionResult,
+        build_target_result: BuildTargetResult,
+        test_suites_with_cases: Iterable[TestSuiteWithCases],
     ) -> None:
         """Run the given build target.
 
@@ -220,11 +436,11 @@ def _run_build_target(
             sut_node: The execution's sut node.
             tg_node: The execution's tg node.
             build_target: A build target's test run configuration.
-            execution: The build target's execution's test run configuration.
-            execution_result: The execution level result object associated with the execution.
+            build_target_result: The build target level result object associated
+                with the current build target.
+            test_suites_with_cases: The test suites with test cases to run.
         """
         self._logger.info(f"Running build target '{build_target.name}'.")
-        build_target_result = execution_result.add_build_target(build_target)
 
         try:
             sut_node.set_up_build_target(build_target)
@@ -236,7 +452,7 @@ def _run_build_target(
             build_target_result.update_setup(Result.FAIL, e)
 
         else:
-            self._run_test_suites(sut_node, tg_node, execution, build_target_result)
+            self._run_test_suites(sut_node, tg_node, build_target_result, test_suites_with_cases)
 
         finally:
             try:
@@ -250,10 +466,10 @@ def _run_test_suites(
         self,
         sut_node: SutNode,
         tg_node: TGNode,
-        execution: ExecutionConfiguration,
         build_target_result: BuildTargetResult,
+        test_suites_with_cases: Iterable[TestSuiteWithCases],
     ) -> None:
-        """Run the execution's (possibly a subset of) test suites using the current build target.
+        """Run `test_suites_with_cases` with the current build target.
 
         The method assumes the build target we're testing has already been built on the SUT node.
         The current build target thus corresponds to the current DPDK build present on the SUT node.
@@ -264,22 +480,20 @@ def _run_test_suites(
         Args:
             sut_node: The execution's SUT node.
             tg_node: The execution's TG node.
-            execution: The execution's test run configuration associated
-                with the current build target.
             build_target_result: The build target level result object associated
                 with the current build target.
+            test_suites_with_cases: The test suites with test cases to run.
         """
         end_build_target = False
-        if not execution.skip_smoke_tests:
-            execution.test_suites[:0] = [TestSuiteConfig.from_dict("smoke_tests")]
-        for test_suite_config in execution.test_suites:
+        for test_suite_with_cases in test_suites_with_cases:
+            test_suite_result = build_target_result.add_test_suite(
+                test_suite_with_cases.test_suite_class.__name__
+            )
             try:
-                self._run_test_suite_module(
-                    sut_node, tg_node, execution, build_target_result, test_suite_config
-                )
+                self._run_test_suite(sut_node, tg_node, test_suite_result, test_suite_with_cases)
             except BlockingTestSuiteError as e:
                 self._logger.exception(
-                    f"An error occurred within {test_suite_config.test_suite}. "
+                    f"An error occurred within {test_suite_with_cases.test_suite_class.__name__}. "
                     "Skipping build target..."
                 )
                 self._result.add_error(e)
@@ -288,15 +502,14 @@ def _run_test_suites(
             if end_build_target:
                 break
 
-    def _run_test_suite_module(
+    def _run_test_suite(
         self,
         sut_node: SutNode,
         tg_node: TGNode,
-        execution: ExecutionConfiguration,
-        build_target_result: BuildTargetResult,
-        test_suite_config: TestSuiteConfig,
+        test_suite_result: TestSuiteResult,
+        test_suite_with_cases: TestSuiteWithCases,
     ) -> None:
-        """Set up, execute and tear down all test suites in a single test suite module.
+        """Set up, execute and tear down `test_suite_with_cases`.
 
         The method assumes the build target we're testing has already been built on the SUT node.
         The current build target thus corresponds to the current DPDK build present on the SUT node.
@@ -306,92 +519,79 @@ def _run_test_suite_module(
 
         Record the setup and the teardown and handle failures.
 
-        The test cases to execute are discovered when creating the :class:`TestSuite` object.
-
         Args:
             sut_node: The execution's SUT node.
             tg_node: The execution's TG node.
-            execution: The execution's test run configuration associated
-                with the current build target.
-            build_target_result: The build target level result object associated
-                with the current build target.
-            test_suite_config: Test suite test run configuration specifying the test suite module
-                and possibly a subset of test cases of test suites in that module.
+            test_suite_result: The test suite level result object associated
+                with the current test suite.
+            test_suite_with_cases: The test suite with test cases to run.
 
         Raises:
             BlockingTestSuiteError: If a blocking test suite fails.
         """
+        test_suite_name = test_suite_with_cases.test_suite_class.__name__
+        test_suite = test_suite_with_cases.test_suite_class(sut_node, tg_node)
         try:
-            full_suite_path = f"tests.TestSuite_{test_suite_config.test_suite}"
-            test_suite_classes = get_test_suites(full_suite_path)
-            suites_str = ", ".join((x.__name__ for x in test_suite_classes))
-            self._logger.debug(f"Found test suites '{suites_str}' in '{full_suite_path}'.")
+            self._logger.info(f"Starting test suite setup: {test_suite_name}")
+            test_suite.set_up_suite()
+            test_suite_result.update_setup(Result.PASS)
+            self._logger.info(f"Test suite setup successful: {test_suite_name}")
         except Exception as e:
-            self._logger.exception("An error occurred when searching for test suites.")
-            self._result.update_setup(Result.ERROR, e)
+            self._logger.exception(f"Test suite setup ERROR: {test_suite_name}")
+            test_suite_result.update_setup(Result.ERROR, e)
 
         else:
-            for test_suite_class in test_suite_classes:
-                test_suite = test_suite_class(sut_node, tg_node, test_suite_config.test_cases)
-
-                test_suite_name = test_suite.__class__.__name__
-                test_suite_result = build_target_result.add_test_suite(test_suite_name)
-                try:
-                    self._logger.info(f"Starting test suite setup: {test_suite_name}")
-                    test_suite.set_up_suite()
-                    test_suite_result.update_setup(Result.PASS)
-                    self._logger.info(f"Test suite setup successful: {test_suite_name}")
-                except Exception as e:
-                    self._logger.exception(f"Test suite setup ERROR: {test_suite_name}")
-                    test_suite_result.update_setup(Result.ERROR, e)
-
-                else:
-                    self._execute_test_suite(execution.func, test_suite, test_suite_result)
-
-                finally:
-                    try:
-                        test_suite.tear_down_suite()
-                        sut_node.kill_cleanup_dpdk_apps()
-                        test_suite_result.update_teardown(Result.PASS)
-                    except Exception as e:
-                        self._logger.exception(f"Test suite teardown ERROR: {test_suite_name}")
-                        self._logger.warning(
-                            f"Test suite '{test_suite_name}' teardown failed, "
-                            f"the next test suite may be affected."
-                        )
-                        test_suite_result.update_setup(Result.ERROR, e)
-                    if len(test_suite_result.get_errors()) > 0 and test_suite.is_blocking:
-                        raise BlockingTestSuiteError(test_suite_name)
+            self._execute_test_suite(
+                test_suite,
+                test_suite_with_cases.test_cases,
+                test_suite_result,
+            )
+        finally:
+            try:
+                test_suite.tear_down_suite()
+                sut_node.kill_cleanup_dpdk_apps()
+                test_suite_result.update_teardown(Result.PASS)
+            except Exception as e:
+                self._logger.exception(f"Test suite teardown ERROR: {test_suite_name}")
+                self._logger.warning(
+                    f"Test suite '{test_suite_name}' teardown failed, "
+                    "the next test suite may be affected."
+                )
+                test_suite_result.update_setup(Result.ERROR, e)
+            if len(test_suite_result.get_errors()) > 0 and test_suite.is_blocking:
+                raise BlockingTestSuiteError(test_suite_name)
 
     def _execute_test_suite(
-        self, func: bool, test_suite: TestSuite, test_suite_result: TestSuiteResult
+        self,
+        test_suite: TestSuite,
+        test_cases: Iterable[MethodType],
+        test_suite_result: TestSuiteResult,
     ) -> None:
-        """Execute all discovered test cases in `test_suite`.
+        """Execute all `test_cases` in `test_suite`.
 
         If the :option:`--re-run` command line argument or the :envvar:`DTS_RERUN` environment
         variable is set, in case of a test case failure, the test case will be executed again
         until it passes or it fails that many times in addition of the first failure.
 
         Args:
-            func: Whether to execute functional test cases.
             test_suite: The test suite object.
+            test_cases: The list of test case methods.
             test_suite_result: The test suite level result object associated
                 with the current test suite.
         """
-        if func:
-            for test_case_method in test_suite._get_functional_test_cases():
-                test_case_name = test_case_method.__name__
-                test_case_result = test_suite_result.add_test_case(test_case_name)
-                all_attempts = SETTINGS.re_run + 1
-                attempt_nr = 1
+        for test_case_method in test_cases:
+            test_case_name = test_case_method.__name__
+            test_case_result = test_suite_result.add_test_case(test_case_name)
+            all_attempts = SETTINGS.re_run + 1
+            attempt_nr = 1
+            self._run_test_case(test_suite, test_case_method, test_case_result)
+            while not test_case_result and attempt_nr < all_attempts:
+                attempt_nr += 1
+                self._logger.info(
+                    f"Re-running FAILED test case '{test_case_name}'. "
+                    f"Attempt number {attempt_nr} out of {all_attempts}."
+                )
                 self._run_test_case(test_suite, test_case_method, test_case_result)
-                while not test_case_result and attempt_nr < all_attempts:
-                    attempt_nr += 1
-                    self._logger.info(
-                        f"Re-running FAILED test case '{test_case_name}'. "
-                        f"Attempt number {attempt_nr} out of {all_attempts}."
-                    )
-                    self._run_test_case(test_suite, test_case_method, test_case_result)
 
     def _run_test_case(
         self,
@@ -399,7 +599,7 @@ def _run_test_case(
         test_case_method: MethodType,
         test_case_result: TestCaseResult,
     ) -> None:
-        """Setup, execute and teardown a test case in `test_suite`.
+        """Setup, execute and teardown `test_case_method` from `test_suite`.
 
         Record the result of the setup and the teardown and handle failures.
 
@@ -424,7 +624,7 @@ def _run_test_case(
 
         else:
             # run test case if setup was successful
-            self._execute_test_case(test_case_method, test_case_result)
+            self._execute_test_case(test_suite, test_case_method, test_case_result)
 
         finally:
             try:
@@ -440,11 +640,15 @@ def _run_test_case(
                 test_case_result.update(Result.ERROR)
 
     def _execute_test_case(
-        self, test_case_method: MethodType, test_case_result: TestCaseResult
+        self,
+        test_suite: TestSuite,
+        test_case_method: MethodType,
+        test_case_result: TestCaseResult,
     ) -> None:
-        """Execute one test case, record the result and handle failures.
+        """Execute `test_case_method` from `test_suite`, record the result and handle failures.
 
         Args:
+            test_suite: The test suite object.
             test_case_method: The test case method.
             test_case_result: The test case level result object associated
                 with the current test case.
@@ -452,7 +656,7 @@ def _execute_test_case(
         test_case_name = test_case_method.__name__
         try:
             self._logger.info(f"Starting test case execution: {test_case_name}")
-            test_case_method()
+            test_case_method(test_suite)
             test_case_result.update(Result.PASS)
             self._logger.info(f"Test case execution PASSED: {test_case_name}")
 
diff --git a/dts/framework/settings.py b/dts/framework/settings.py
index 609c8d0e62..2b8bfbe0ed 100644
--- a/dts/framework/settings.py
+++ b/dts/framework/settings.py
@@ -253,8 +253,7 @@ def _get_parser() -> argparse.ArgumentParser:
         "--test-cases",
         action=_env_arg("DTS_TESTCASES"),
         default="",
-        help="[DTS_TESTCASES] Comma-separated list of test cases to execute. "
-        "Unknown test cases will be silently ignored.",
+        help="[DTS_TESTCASES] Comma-separated list of test cases to execute.",
     )
 
     parser.add_argument(
diff --git a/dts/framework/test_result.py b/dts/framework/test_result.py
index 4467749a9d..075195fd5b 100644
--- a/dts/framework/test_result.py
+++ b/dts/framework/test_result.py
@@ -25,7 +25,9 @@
 
 import os.path
 from collections.abc import MutableSequence
+from dataclasses import dataclass
 from enum import Enum, auto
+from types import MethodType
 
 from .config import (
     OS,
@@ -36,10 +38,42 @@
     CPUType,
     NodeConfiguration,
     NodeInfo,
+    TestSuiteConfig,
 )
 from .exception import DTSError, ErrorSeverity
 from .logger import DTSLOG
 from .settings import SETTINGS
+from .test_suite import TestSuite
+
+
+@dataclass(slots=True, frozen=True)
+class TestSuiteWithCases:
+    """A test suite class with test case methods.
+
+    An auxiliary class holding a test case class with test case methods. The intended use of this
+    class is to hold a subset of test cases (which could be all test cases) because we don't have
+    all the data to instantiate the class at the point of inspection. The knowledge of this subset
+    is needed in case an error occurs before the class is instantiated and we need to record
+    which test cases were blocked by the error.
+
+    Attributes:
+        test_suite_class: The test suite class.
+        test_cases: The test case methods.
+    """
+
+    test_suite_class: type[TestSuite]
+    test_cases: list[MethodType]
+
+    def create_config(self) -> TestSuiteConfig:
+        """Generate a :class:`TestSuiteConfig` from the stored test suite with test cases.
+
+        Returns:
+            The :class:`TestSuiteConfig` representation.
+        """
+        return TestSuiteConfig(
+            test_suite=self.test_suite_class.__name__,
+            test_cases=[test_case.__name__ for test_case in self.test_cases],
+        )
 
 
 class Result(Enum):
diff --git a/dts/framework/test_suite.py b/dts/framework/test_suite.py
index b02fd36147..f9fe88093e 100644
--- a/dts/framework/test_suite.py
+++ b/dts/framework/test_suite.py
@@ -11,25 +11,17 @@
     * Testbed (SUT, TG) configuration,
     * Packet sending and verification,
     * Test case verification.
-
-The module also defines a function, :func:`get_test_suites`,
-for gathering test suites from a Python module.
 """
 
-import importlib
-import inspect
-import re
 from ipaddress import IPv4Interface, IPv6Interface, ip_interface
-from types import MethodType
-from typing import Any, ClassVar, Union
+from typing import ClassVar, Union
 
 from scapy.layers.inet import IP  # type: ignore[import]
 from scapy.layers.l2 import Ether  # type: ignore[import]
 from scapy.packet import Packet, Padding  # type: ignore[import]
 
-from .exception import ConfigurationError, TestCaseVerifyError
+from .exception import TestCaseVerifyError
 from .logger import DTSLOG, getLogger
-from .settings import SETTINGS
 from .testbed_model import Port, PortLink, SutNode, TGNode
 from .utils import get_packet_summaries
 
@@ -37,7 +29,6 @@
 class TestSuite(object):
     """The base class with building blocks needed by most test cases.
 
-        * Test case filtering and collection,
         * Test suite setup/cleanup methods to override,
         * Test case setup/cleanup methods to override,
         * Test case verification,
@@ -71,7 +62,6 @@ class TestSuite(object):
     #: will block the execution of all subsequent test suites in the current build target.
     is_blocking: ClassVar[bool] = False
     _logger: DTSLOG
-    _test_cases_to_run: list[str]
     _port_links: list[PortLink]
     _sut_port_ingress: Port
     _sut_port_egress: Port
@@ -86,24 +76,19 @@ def __init__(
         self,
         sut_node: SutNode,
         tg_node: TGNode,
-        test_cases: list[str],
     ):
         """Initialize the test suite testbed information and basic configuration.
 
-        Process what test cases to run, find links between ports and set up
-        default IP addresses to be used when configuring them.
+        Find links between ports and set up default IP addresses to be used when
+        configuring them.
 
         Args:
             sut_node: The SUT node where the test suite will run.
             tg_node: The TG node where the test suite will run.
-            test_cases: The list of test cases to execute.
-                If empty, all test cases will be executed.
         """
         self.sut_node = sut_node
         self.tg_node = tg_node
         self._logger = getLogger(self.__class__.__name__)
-        self._test_cases_to_run = test_cases
-        self._test_cases_to_run.extend(SETTINGS.test_cases)
         self._port_links = []
         self._process_links()
         self._sut_port_ingress, self._tg_port_egress = (
@@ -364,65 +349,3 @@ def _verify_l3_packet(self, received_packet: IP, expected_packet: IP) -> bool:
         if received_packet.src != expected_packet.src or received_packet.dst != expected_packet.dst:
             return False
         return True
-
-    def _get_functional_test_cases(self) -> list[MethodType]:
-        """Get all functional test cases defined in this TestSuite.
-
-        Returns:
-            The list of functional test cases of this TestSuite.
-        """
-        return self._get_test_cases(r"test_(?!perf_)")
-
-    def _get_test_cases(self, test_case_regex: str) -> list[MethodType]:
-        """Return a list of test cases matching test_case_regex.
-
-        Returns:
-            The list of test cases matching test_case_regex of this TestSuite.
-        """
-        self._logger.debug(f"Searching for test cases in {self.__class__.__name__}.")
-        filtered_test_cases = []
-        for test_case_name, test_case in inspect.getmembers(self, inspect.ismethod):
-            if self._should_be_executed(test_case_name, test_case_regex):
-                filtered_test_cases.append(test_case)
-        cases_str = ", ".join((x.__name__ for x in filtered_test_cases))
-        self._logger.debug(f"Found test cases '{cases_str}' in {self.__class__.__name__}.")
-        return filtered_test_cases
-
-    def _should_be_executed(self, test_case_name: str, test_case_regex: str) -> bool:
-        """Check whether the test case should be scheduled to be executed."""
-        match = bool(re.match(test_case_regex, test_case_name))
-        if self._test_cases_to_run:
-            return match and test_case_name in self._test_cases_to_run
-
-        return match
-
-
-def get_test_suites(testsuite_module_path: str) -> list[type[TestSuite]]:
-    r"""Find all :class:`TestSuite`\s in a Python module.
-
-    Args:
-        testsuite_module_path: The path to the Python module.
-
-    Returns:
-        The list of :class:`TestSuite`\s found within the Python module.
-
-    Raises:
-        ConfigurationError: The test suite module was not found.
-    """
-
-    def is_test_suite(object: Any) -> bool:
-        try:
-            if issubclass(object, TestSuite) and object is not TestSuite:
-                return True
-        except TypeError:
-            return False
-        return False
-
-    try:
-        testcase_module = importlib.import_module(testsuite_module_path)
-    except ModuleNotFoundError as e:
-        raise ConfigurationError(f"Test suite '{testsuite_module_path}' not found.") from e
-    return [
-        test_suite_class
-        for _, test_suite_class in inspect.getmembers(testcase_module, is_test_suite)
-    ]
diff --git a/dts/pyproject.toml b/dts/pyproject.toml
index 28bd970ae4..8eb92b4f11 100644
--- a/dts/pyproject.toml
+++ b/dts/pyproject.toml
@@ -51,6 +51,9 @@ linters = "mccabe,pycodestyle,pydocstyle,pyflakes"
 format = "pylint"
 max_line_length = 100
 
+[tool.pylama.linter.pycodestyle]
+ignore = "E203,W503"
+
 [tool.pylama.linter.pydocstyle]
 convention = "google"
 
diff --git a/dts/tests/TestSuite_smoke_tests.py b/dts/tests/TestSuite_smoke_tests.py
index 5e2bac14bd..7b2a0e97f8 100644
--- a/dts/tests/TestSuite_smoke_tests.py
+++ b/dts/tests/TestSuite_smoke_tests.py
@@ -21,7 +21,7 @@
 from framework.utils import REGEX_FOR_PCI_ADDRESS
 
 
-class SmokeTests(TestSuite):
+class TestSmokeTests(TestSuite):
     """DPDK and infrastructure smoke test suite.
 
     The test cases validate the most basic DPDK functionality needed for all other test suites.
-- 
2.34.1


^ permalink raw reply	[flat|nested] 28+ messages in thread

* [PATCH v2 4/7] dts: reorganize test result
  2024-02-06 14:57 ` [PATCH v2 0/7] " Juraj Linkeš
                     ` (2 preceding siblings ...)
  2024-02-06 14:57   ` [PATCH v2 3/7] dts: filter test suites in executions Juraj Linkeš
@ 2024-02-06 14:57   ` Juraj Linkeš
  2024-02-06 14:57   ` [PATCH v2 5/7] dts: block all test cases when earlier setup fails Juraj Linkeš
                     ` (2 subsequent siblings)
  6 siblings, 0 replies; 28+ messages in thread
From: Juraj Linkeš @ 2024-02-06 14:57 UTC (permalink / raw)
  To: thomas, Honnappa.Nagarahalli, jspewock, probb, paul.szczepanek,
	Luca.Vizzarro
  Cc: dev, Juraj Linkeš

The current order of Result classes in the test_suite.py module is
guided by the needs of type hints, which is not as intuitively readable
as ordering them by the occurrences in code. The order goes from the
topmost level to lowermost:
BaseResult
DTSResult
ExecutionResult
BuildTargetResult
TestSuiteResult
TestCaseResult

This is the same order as they're used in the runner module and they're
also used in the same order between themselves in the test_result
module.

Signed-off-by: Juraj Linkeš <juraj.linkes@pantheon.tech>
---
 dts/framework/test_result.py | 411 ++++++++++++++++++-----------------
 1 file changed, 206 insertions(+), 205 deletions(-)

diff --git a/dts/framework/test_result.py b/dts/framework/test_result.py
index 075195fd5b..abdbafab10 100644
--- a/dts/framework/test_result.py
+++ b/dts/framework/test_result.py
@@ -28,6 +28,7 @@
 from dataclasses import dataclass
 from enum import Enum, auto
 from types import MethodType
+from typing import Union
 
 from .config import (
     OS,
@@ -129,58 +130,6 @@ def __bool__(self) -> bool:
         return bool(self.result)
 
 
-class Statistics(dict):
-    """How many test cases ended in which result state along some other basic information.
-
-    Subclassing :class:`dict` provides a convenient way to format the data.
-
-    The data are stored in the following keys:
-
-    * **PASS RATE** (:class:`int`) -- The FAIL/PASS ratio of all test cases.
-    * **DPDK VERSION** (:class:`str`) -- The tested DPDK version.
-    """
-
-    def __init__(self, dpdk_version: str | None):
-        """Extend the constructor with keys in which the data are stored.
-
-        Args:
-            dpdk_version: The version of tested DPDK.
-        """
-        super(Statistics, self).__init__()
-        for result in Result:
-            self[result.name] = 0
-        self["PASS RATE"] = 0.0
-        self["DPDK VERSION"] = dpdk_version
-
-    def __iadd__(self, other: Result) -> "Statistics":
-        """Add a Result to the final count.
-
-        Example:
-            stats: Statistics = Statistics()  # empty Statistics
-            stats += Result.PASS  # add a Result to `stats`
-
-        Args:
-            other: The Result to add to this statistics object.
-
-        Returns:
-            The modified statistics object.
-        """
-        self[other.name] += 1
-        self["PASS RATE"] = (
-            float(self[Result.PASS.name]) * 100 / sum(self[result.name] for result in Result)
-        )
-        return self
-
-    def __str__(self) -> str:
-        """Each line contains the formatted key = value pair."""
-        stats_str = ""
-        for key, value in self.items():
-            stats_str += f"{key:<12} = {value}\n"
-            # according to docs, we should use \n when writing to text files
-            # on all platforms
-        return stats_str
-
-
 class BaseResult(object):
     """Common data and behavior of DTS results.
 
@@ -245,7 +194,7 @@ def get_errors(self) -> list[Exception]:
         """
         return self._get_setup_teardown_errors() + self._get_inner_errors()
 
-    def add_stats(self, statistics: Statistics) -> None:
+    def add_stats(self, statistics: "Statistics") -> None:
         """Collate stats from the whole result hierarchy.
 
         Args:
@@ -255,91 +204,149 @@ def add_stats(self, statistics: Statistics) -> None:
             inner_result.add_stats(statistics)
 
 
-class TestCaseResult(BaseResult, FixtureResult):
-    r"""The test case specific result.
+class DTSResult(BaseResult):
+    """Stores environment information and test results from a DTS run.
 
-    Stores the result of the actual test case. This is done by adding an extra superclass
-    in :class:`FixtureResult`. The setup and teardown results are :class:`FixtureResult`\s and
-    the class is itself a record of the test case.
+        * Execution level information, such as testbed and the test suite list,
+        * Build target level information, such as compiler, target OS and cpu,
+        * Test suite and test case results,
+        * All errors that are caught and recorded during DTS execution.
+
+    The information is stored hierarchically. This is the first level of the hierarchy
+    and as such is where the data form the whole hierarchy is collated or processed.
+
+    The internal list stores the results of all executions.
 
     Attributes:
-        test_case_name: The test case name.
+        dpdk_version: The DPDK version to record.
     """
 
-    test_case_name: str
+    dpdk_version: str | None
+    _logger: DTSLOG
+    _errors: list[Exception]
+    _return_code: ErrorSeverity
+    _stats_result: Union["Statistics", None]
+    _stats_filename: str
 
-    def __init__(self, test_case_name: str):
-        """Extend the constructor with `test_case_name`.
+    def __init__(self, logger: DTSLOG):
+        """Extend the constructor with top-level specifics.
 
         Args:
-            test_case_name: The test case's name.
+            logger: The logger instance the whole result will use.
         """
-        super(TestCaseResult, self).__init__()
-        self.test_case_name = test_case_name
+        super(DTSResult, self).__init__()
+        self.dpdk_version = None
+        self._logger = logger
+        self._errors = []
+        self._return_code = ErrorSeverity.NO_ERR
+        self._stats_result = None
+        self._stats_filename = os.path.join(SETTINGS.output_dir, "statistics.txt")
 
-    def update(self, result: Result, error: Exception | None = None) -> None:
-        """Update the test case result.
+    def add_execution(self, sut_node: NodeConfiguration) -> "ExecutionResult":
+        """Add and return the inner result (execution).
 
-        This updates the result of the test case itself and doesn't affect
-        the results of the setup and teardown steps in any way.
+        Args:
+            sut_node: The SUT node's test run configuration.
+
+        Returns:
+            The execution's result.
+        """
+        execution_result = ExecutionResult(sut_node)
+        self._inner_results.append(execution_result)
+        return execution_result
+
+    def add_error(self, error: Exception) -> None:
+        """Record an error that occurred outside any execution.
 
         Args:
-            result: The result of the test case.
-            error: The error that occurred in case of a failure.
+            error: The exception to record.
         """
-        self.result = result
-        self.error = error
+        self._errors.append(error)
 
-    def _get_inner_errors(self) -> list[Exception]:
-        if self.error:
-            return [self.error]
-        return []
+    def process(self) -> None:
+        """Process the data after a whole DTS run.
 
-    def add_stats(self, statistics: Statistics) -> None:
-        r"""Add the test case result to statistics.
+        The data is added to inner objects during runtime and this object is not updated
+        at that time. This requires us to process the inner data after it's all been gathered.
 
-        The base method goes through the hierarchy recursively and this method is here to stop
-        the recursion, as the :class:`TestCaseResult`\s are the leaves of the hierarchy tree.
+        The processing gathers all errors and the statistics of test case results.
+        """
+        self._errors += self.get_errors()
+        if self._errors and self._logger:
+            self._logger.debug("Summary of errors:")
+            for error in self._errors:
+                self._logger.debug(repr(error))
 
-        Args:
-            statistics: The :class:`Statistics` object where the stats will be added.
+        self._stats_result = Statistics(self.dpdk_version)
+        self.add_stats(self._stats_result)
+        with open(self._stats_filename, "w+") as stats_file:
+            stats_file.write(str(self._stats_result))
+
+    def get_return_code(self) -> int:
+        """Go through all stored Exceptions and return the final DTS error code.
+
+        Returns:
+            The highest error code found.
         """
-        statistics += self.result
+        for error in self._errors:
+            error_return_code = ErrorSeverity.GENERIC_ERR
+            if isinstance(error, DTSError):
+                error_return_code = error.severity
 
-    def __bool__(self) -> bool:
-        """The test case passed only if setup, teardown and the test case itself passed."""
-        return bool(self.setup_result) and bool(self.teardown_result) and bool(self.result)
+            if error_return_code > self._return_code:
+                self._return_code = error_return_code
 
+        return int(self._return_code)
 
-class TestSuiteResult(BaseResult):
-    """The test suite specific result.
 
-    The internal list stores the results of all test cases in a given test suite.
+class ExecutionResult(BaseResult):
+    """The execution specific result.
+
+    The internal list stores the results of all build targets in a given execution.
 
     Attributes:
-        suite_name: The test suite name.
+        sut_node: The SUT node used in the execution.
+        sut_os_name: The operating system of the SUT node.
+        sut_os_version: The operating system version of the SUT node.
+        sut_kernel_version: The operating system kernel version of the SUT node.
     """
 
-    suite_name: str
+    sut_node: NodeConfiguration
+    sut_os_name: str
+    sut_os_version: str
+    sut_kernel_version: str
 
-    def __init__(self, suite_name: str):
-        """Extend the constructor with `suite_name`.
+    def __init__(self, sut_node: NodeConfiguration):
+        """Extend the constructor with the `sut_node`'s config.
 
         Args:
-            suite_name: The test suite's name.
+            sut_node: The SUT node's test run configuration used in the execution.
         """
-        super(TestSuiteResult, self).__init__()
-        self.suite_name = suite_name
+        super(ExecutionResult, self).__init__()
+        self.sut_node = sut_node
 
-    def add_test_case(self, test_case_name: str) -> TestCaseResult:
-        """Add and return the inner result (test case).
+    def add_build_target(self, build_target: BuildTargetConfiguration) -> "BuildTargetResult":
+        """Add and return the inner result (build target).
+
+        Args:
+            build_target: The build target's test run configuration.
 
         Returns:
-            The test case's result.
+            The build target's result.
         """
-        test_case_result = TestCaseResult(test_case_name)
-        self._inner_results.append(test_case_result)
-        return test_case_result
+        build_target_result = BuildTargetResult(build_target)
+        self._inner_results.append(build_target_result)
+        return build_target_result
+
+    def add_sut_info(self, sut_info: NodeInfo) -> None:
+        """Add SUT information gathered at runtime.
+
+        Args:
+            sut_info: The additional SUT node information.
+        """
+        self.sut_os_name = sut_info.os_name
+        self.sut_os_version = sut_info.os_version
+        self.sut_kernel_version = sut_info.kernel_version
 
 
 class BuildTargetResult(BaseResult):
@@ -386,7 +393,7 @@ def add_build_target_info(self, versions: BuildTargetInfo) -> None:
         self.compiler_version = versions.compiler_version
         self.dpdk_version = versions.dpdk_version
 
-    def add_test_suite(self, test_suite_name: str) -> TestSuiteResult:
+    def add_test_suite(self, test_suite_name: str) -> "TestSuiteResult":
         """Add and return the inner result (test suite).
 
         Returns:
@@ -397,146 +404,140 @@ def add_test_suite(self, test_suite_name: str) -> TestSuiteResult:
         return test_suite_result
 
 
-class ExecutionResult(BaseResult):
-    """The execution specific result.
+class TestSuiteResult(BaseResult):
+    """The test suite specific result.
 
-    The internal list stores the results of all build targets in a given execution.
+    The internal list stores the results of all test cases in a given test suite.
 
     Attributes:
-        sut_node: The SUT node used in the execution.
-        sut_os_name: The operating system of the SUT node.
-        sut_os_version: The operating system version of the SUT node.
-        sut_kernel_version: The operating system kernel version of the SUT node.
+        suite_name: The test suite name.
     """
 
-    sut_node: NodeConfiguration
-    sut_os_name: str
-    sut_os_version: str
-    sut_kernel_version: str
+    suite_name: str
 
-    def __init__(self, sut_node: NodeConfiguration):
-        """Extend the constructor with the `sut_node`'s config.
+    def __init__(self, suite_name: str):
+        """Extend the constructor with `suite_name`.
 
         Args:
-            sut_node: The SUT node's test run configuration used in the execution.
+            suite_name: The test suite's name.
         """
-        super(ExecutionResult, self).__init__()
-        self.sut_node = sut_node
-
-    def add_build_target(self, build_target: BuildTargetConfiguration) -> BuildTargetResult:
-        """Add and return the inner result (build target).
+        super(TestSuiteResult, self).__init__()
+        self.suite_name = suite_name
 
-        Args:
-            build_target: The build target's test run configuration.
+    def add_test_case(self, test_case_name: str) -> "TestCaseResult":
+        """Add and return the inner result (test case).
 
         Returns:
-            The build target's result.
-        """
-        build_target_result = BuildTargetResult(build_target)
-        self._inner_results.append(build_target_result)
-        return build_target_result
-
-    def add_sut_info(self, sut_info: NodeInfo) -> None:
-        """Add SUT information gathered at runtime.
-
-        Args:
-            sut_info: The additional SUT node information.
+            The test case's result.
         """
-        self.sut_os_name = sut_info.os_name
-        self.sut_os_version = sut_info.os_version
-        self.sut_kernel_version = sut_info.kernel_version
+        test_case_result = TestCaseResult(test_case_name)
+        self._inner_results.append(test_case_result)
+        return test_case_result
 
 
-class DTSResult(BaseResult):
-    """Stores environment information and test results from a DTS run.
-
-        * Execution level information, such as testbed and the test suite list,
-        * Build target level information, such as compiler, target OS and cpu,
-        * Test suite and test case results,
-        * All errors that are caught and recorded during DTS execution.
-
-    The information is stored hierarchically. This is the first level of the hierarchy
-    and as such is where the data form the whole hierarchy is collated or processed.
+class TestCaseResult(BaseResult, FixtureResult):
+    r"""The test case specific result.
 
-    The internal list stores the results of all executions.
+    Stores the result of the actual test case. This is done by adding an extra superclass
+    in :class:`FixtureResult`. The setup and teardown results are :class:`FixtureResult`\s and
+    the class is itself a record of the test case.
 
     Attributes:
-        dpdk_version: The DPDK version to record.
+        test_case_name: The test case name.
     """
 
-    dpdk_version: str | None
-    _logger: DTSLOG
-    _errors: list[Exception]
-    _return_code: ErrorSeverity
-    _stats_result: Statistics | None
-    _stats_filename: str
+    test_case_name: str
 
-    def __init__(self, logger: DTSLOG):
-        """Extend the constructor with top-level specifics.
+    def __init__(self, test_case_name: str):
+        """Extend the constructor with `test_case_name`.
 
         Args:
-            logger: The logger instance the whole result will use.
+            test_case_name: The test case's name.
         """
-        super(DTSResult, self).__init__()
-        self.dpdk_version = None
-        self._logger = logger
-        self._errors = []
-        self._return_code = ErrorSeverity.NO_ERR
-        self._stats_result = None
-        self._stats_filename = os.path.join(SETTINGS.output_dir, "statistics.txt")
+        super(TestCaseResult, self).__init__()
+        self.test_case_name = test_case_name
 
-    def add_execution(self, sut_node: NodeConfiguration) -> ExecutionResult:
-        """Add and return the inner result (execution).
+    def update(self, result: Result, error: Exception | None = None) -> None:
+        """Update the test case result.
 
-        Args:
-            sut_node: The SUT node's test run configuration.
+        This updates the result of the test case itself and doesn't affect
+        the results of the setup and teardown steps in any way.
 
-        Returns:
-            The execution's result.
+        Args:
+            result: The result of the test case.
+            error: The error that occurred in case of a failure.
         """
-        execution_result = ExecutionResult(sut_node)
-        self._inner_results.append(execution_result)
-        return execution_result
+        self.result = result
+        self.error = error
 
-    def add_error(self, error: Exception) -> None:
-        """Record an error that occurred outside any execution.
+    def _get_inner_errors(self) -> list[Exception]:
+        if self.error:
+            return [self.error]
+        return []
+
+    def add_stats(self, statistics: "Statistics") -> None:
+        r"""Add the test case result to statistics.
+
+        The base method goes through the hierarchy recursively and this method is here to stop
+        the recursion, as the :class:`TestCaseResult`\s are the leaves of the hierarchy tree.
 
         Args:
-            error: The exception to record.
+            statistics: The :class:`Statistics` object where the stats will be added.
         """
-        self._errors.append(error)
+        statistics += self.result
 
-    def process(self) -> None:
-        """Process the data after a whole DTS run.
+    def __bool__(self) -> bool:
+        """The test case passed only if setup, teardown and the test case itself passed."""
+        return bool(self.setup_result) and bool(self.teardown_result) and bool(self.result)
 
-        The data is added to inner objects during runtime and this object is not updated
-        at that time. This requires us to process the inner data after it's all been gathered.
 
-        The processing gathers all errors and the statistics of test case results.
+class Statistics(dict):
+    """How many test cases ended in which result state along some other basic information.
+
+    Subclassing :class:`dict` provides a convenient way to format the data.
+
+    The data are stored in the following keys:
+
+    * **PASS RATE** (:class:`int`) -- The FAIL/PASS ratio of all test cases.
+    * **DPDK VERSION** (:class:`str`) -- The tested DPDK version.
+    """
+
+    def __init__(self, dpdk_version: str | None):
+        """Extend the constructor with keys in which the data are stored.
+
+        Args:
+            dpdk_version: The version of tested DPDK.
         """
-        self._errors += self.get_errors()
-        if self._errors and self._logger:
-            self._logger.debug("Summary of errors:")
-            for error in self._errors:
-                self._logger.debug(repr(error))
+        super(Statistics, self).__init__()
+        for result in Result:
+            self[result.name] = 0
+        self["PASS RATE"] = 0.0
+        self["DPDK VERSION"] = dpdk_version
 
-        self._stats_result = Statistics(self.dpdk_version)
-        self.add_stats(self._stats_result)
-        with open(self._stats_filename, "w+") as stats_file:
-            stats_file.write(str(self._stats_result))
+    def __iadd__(self, other: Result) -> "Statistics":
+        """Add a Result to the final count.
 
-    def get_return_code(self) -> int:
-        """Go through all stored Exceptions and return the final DTS error code.
+        Example:
+            stats: Statistics = Statistics()  # empty Statistics
+            stats += Result.PASS  # add a Result to `stats`
+
+        Args:
+            other: The Result to add to this statistics object.
 
         Returns:
-            The highest error code found.
+            The modified statistics object.
         """
-        for error in self._errors:
-            error_return_code = ErrorSeverity.GENERIC_ERR
-            if isinstance(error, DTSError):
-                error_return_code = error.severity
-
-            if error_return_code > self._return_code:
-                self._return_code = error_return_code
+        self[other.name] += 1
+        self["PASS RATE"] = (
+            float(self[Result.PASS.name]) * 100 / sum(self[result.name] for result in Result)
+        )
+        return self
 
-        return int(self._return_code)
+    def __str__(self) -> str:
+        """Each line contains the formatted key = value pair."""
+        stats_str = ""
+        for key, value in self.items():
+            stats_str += f"{key:<12} = {value}\n"
+            # according to docs, we should use \n when writing to text files
+            # on all platforms
+        return stats_str
-- 
2.34.1


^ permalink raw reply	[flat|nested] 28+ messages in thread

* [PATCH v2 5/7] dts: block all test cases when earlier setup fails
  2024-02-06 14:57 ` [PATCH v2 0/7] " Juraj Linkeš
                     ` (3 preceding siblings ...)
  2024-02-06 14:57   ` [PATCH v2 4/7] dts: reorganize test result Juraj Linkeš
@ 2024-02-06 14:57   ` Juraj Linkeš
  2024-02-06 14:57   ` [PATCH v2 6/7] dts: refactor logging configuration Juraj Linkeš
  2024-02-06 14:57   ` [PATCH v2 7/7] dts: improve test suite and case filtering Juraj Linkeš
  6 siblings, 0 replies; 28+ messages in thread
From: Juraj Linkeš @ 2024-02-06 14:57 UTC (permalink / raw)
  To: thomas, Honnappa.Nagarahalli, jspewock, probb, paul.szczepanek,
	Luca.Vizzarro
  Cc: dev, Juraj Linkeš

In case of a failure before a test suite, the child results will be
recursively recorded as blocked, giving us a full report which was
missing previously.

Signed-off-by: Juraj Linkeš <juraj.linkes@pantheon.tech>
---
 dts/framework/runner.py      |  21 ++--
 dts/framework/test_result.py | 186 +++++++++++++++++++++++++----------
 2 files changed, 148 insertions(+), 59 deletions(-)

diff --git a/dts/framework/runner.py b/dts/framework/runner.py
index 3e95cf9e26..f58b0adc13 100644
--- a/dts/framework/runner.py
+++ b/dts/framework/runner.py
@@ -60,13 +60,15 @@ class DTSRunner:
     Each setup or teardown of each stage is recorded in a :class:`~framework.test_result.DTSResult`
     or one of its subclasses. The test case results are also recorded.
 
-    If an error occurs, the current stage is aborted, the error is recorded and the run continues in
-    the next iteration of the same stage. The return code is the highest `severity` of all
+    If an error occurs, the current stage is aborted, the error is recorded, everything in
+    the inner stages is marked as blocked and the run continues in the next iteration
+    of the same stage. The return code is the highest `severity` of all
     :class:`~.framework.exception.DTSError`\s.
 
     Example:
-        An error occurs in a build target setup. The current build target is aborted and the run
-        continues with the next build target. If the errored build target was the last one in the
+        An error occurs in a build target setup. The current build target is aborted,
+        all test suites and their test cases are marked as blocked and the run continues
+        with the next build target. If the errored build target was the last one in the
         given execution, the next execution begins.
     """
 
@@ -100,6 +102,10 @@ def run(self):
         test case within the test suite is set up, executed and torn down. After all test cases
         have been executed, the test suite is torn down and the next build target will be tested.
 
+        In order to properly mark test suites and test cases as blocked in case of a failure,
+        we need to have discovered which test suites and test cases to run before any failures
+        happen. The discovery happens at the earliest point at the start of each execution.
+
         All the nested steps look like this:
 
             #. Execution setup
@@ -134,11 +140,12 @@ def run(self):
                 self._logger.info(
                     f"Running execution with SUT '{execution.system_under_test_node.name}'."
                 )
-                execution_result = self._result.add_execution(execution.system_under_test_node)
+                execution_result = self._result.add_execution(execution)
                 try:
                     test_suites_with_cases = self._get_test_suites_with_cases(
                         execution.test_suites, execution.func, execution.perf
                     )
+                    execution_result.test_suites_with_cases = test_suites_with_cases
                 except Exception as e:
                     self._logger.exception(
                         f"Invalid test suite configuration found: " f"{execution.test_suites}."
@@ -486,9 +493,7 @@ def _run_test_suites(
         """
         end_build_target = False
         for test_suite_with_cases in test_suites_with_cases:
-            test_suite_result = build_target_result.add_test_suite(
-                test_suite_with_cases.test_suite_class.__name__
-            )
+            test_suite_result = build_target_result.add_test_suite(test_suite_with_cases)
             try:
                 self._run_test_suite(sut_node, tg_node, test_suite_result, test_suite_with_cases)
             except BlockingTestSuiteError as e:
diff --git a/dts/framework/test_result.py b/dts/framework/test_result.py
index abdbafab10..eedb2d20ee 100644
--- a/dts/framework/test_result.py
+++ b/dts/framework/test_result.py
@@ -37,7 +37,7 @@
     BuildTargetInfo,
     Compiler,
     CPUType,
-    NodeConfiguration,
+    ExecutionConfiguration,
     NodeInfo,
     TestSuiteConfig,
 )
@@ -88,6 +88,8 @@ class Result(Enum):
     ERROR = auto()
     #:
     SKIP = auto()
+    #:
+    BLOCK = auto()
 
     def __bool__(self) -> bool:
         """Only PASS is True."""
@@ -141,21 +143,26 @@ class BaseResult(object):
     Attributes:
         setup_result: The result of the setup of the particular stage.
         teardown_result: The results of the teardown of the particular stage.
+        child_results: The results of the descendants in the results hierarchy.
     """
 
     setup_result: FixtureResult
     teardown_result: FixtureResult
-    _inner_results: MutableSequence["BaseResult"]
+    child_results: MutableSequence["BaseResult"]
 
     def __init__(self):
         """Initialize the constructor."""
         self.setup_result = FixtureResult()
         self.teardown_result = FixtureResult()
-        self._inner_results = []
+        self.child_results = []
 
     def update_setup(self, result: Result, error: Exception | None = None) -> None:
         """Store the setup result.
 
+        If the result is :attr:`~Result.BLOCK`, :attr:`~Result.ERROR` or :attr:`~Result.FAIL`,
+        then the corresponding child results in result hierarchy
+        are also marked with :attr:`~Result.BLOCK`.
+
         Args:
             result: The result of the setup.
             error: The error that occurred in case of a failure.
@@ -163,6 +170,16 @@ def update_setup(self, result: Result, error: Exception | None = None) -> None:
         self.setup_result.result = result
         self.setup_result.error = error
 
+        if result in [Result.BLOCK, Result.ERROR, Result.FAIL]:
+            self.update_teardown(Result.BLOCK)
+            self._block_result()
+
+    def _block_result(self) -> None:
+        r"""Mark the result as :attr:`~Result.BLOCK`\ed.
+
+        The blocking of child results should be done in overloaded methods.
+        """
+
     def update_teardown(self, result: Result, error: Exception | None = None) -> None:
         """Store the teardown result.
 
@@ -181,10 +198,8 @@ def _get_setup_teardown_errors(self) -> list[Exception]:
             errors.append(self.teardown_result.error)
         return errors
 
-    def _get_inner_errors(self) -> list[Exception]:
-        return [
-            error for inner_result in self._inner_results for error in inner_result.get_errors()
-        ]
+    def _get_child_errors(self) -> list[Exception]:
+        return [error for child_result in self.child_results for error in child_result.get_errors()]
 
     def get_errors(self) -> list[Exception]:
         """Compile errors from the whole result hierarchy.
@@ -192,7 +207,7 @@ def get_errors(self) -> list[Exception]:
         Returns:
             The errors from setup, teardown and all errors found in the whole result hierarchy.
         """
-        return self._get_setup_teardown_errors() + self._get_inner_errors()
+        return self._get_setup_teardown_errors() + self._get_child_errors()
 
     def add_stats(self, statistics: "Statistics") -> None:
         """Collate stats from the whole result hierarchy.
@@ -200,8 +215,8 @@ def add_stats(self, statistics: "Statistics") -> None:
         Args:
             statistics: The :class:`Statistics` object where the stats will be collated.
         """
-        for inner_result in self._inner_results:
-            inner_result.add_stats(statistics)
+        for child_result in self.child_results:
+            child_result.add_stats(statistics)
 
 
 class DTSResult(BaseResult):
@@ -242,18 +257,18 @@ def __init__(self, logger: DTSLOG):
         self._stats_result = None
         self._stats_filename = os.path.join(SETTINGS.output_dir, "statistics.txt")
 
-    def add_execution(self, sut_node: NodeConfiguration) -> "ExecutionResult":
-        """Add and return the inner result (execution).
+    def add_execution(self, execution: ExecutionConfiguration) -> "ExecutionResult":
+        """Add and return the child result (execution).
 
         Args:
-            sut_node: The SUT node's test run configuration.
+            execution: The execution's test run configuration.
 
         Returns:
             The execution's result.
         """
-        execution_result = ExecutionResult(sut_node)
-        self._inner_results.append(execution_result)
-        return execution_result
+        result = ExecutionResult(execution)
+        self.child_results.append(result)
+        return result
 
     def add_error(self, error: Exception) -> None:
         """Record an error that occurred outside any execution.
@@ -266,8 +281,8 @@ def add_error(self, error: Exception) -> None:
     def process(self) -> None:
         """Process the data after a whole DTS run.
 
-        The data is added to inner objects during runtime and this object is not updated
-        at that time. This requires us to process the inner data after it's all been gathered.
+        The data is added to child objects during runtime and this object is not updated
+        at that time. This requires us to process the child data after it's all been gathered.
 
         The processing gathers all errors and the statistics of test case results.
         """
@@ -305,28 +320,30 @@ class ExecutionResult(BaseResult):
     The internal list stores the results of all build targets in a given execution.
 
     Attributes:
-        sut_node: The SUT node used in the execution.
         sut_os_name: The operating system of the SUT node.
         sut_os_version: The operating system version of the SUT node.
         sut_kernel_version: The operating system kernel version of the SUT node.
     """
 
-    sut_node: NodeConfiguration
     sut_os_name: str
     sut_os_version: str
     sut_kernel_version: str
+    _config: ExecutionConfiguration
+    _parent_result: DTSResult
+    _test_suites_with_cases: list[TestSuiteWithCases]
 
-    def __init__(self, sut_node: NodeConfiguration):
-        """Extend the constructor with the `sut_node`'s config.
+    def __init__(self, execution: ExecutionConfiguration):
+        """Extend the constructor with the execution's config and DTSResult.
 
         Args:
-            sut_node: The SUT node's test run configuration used in the execution.
+            execution: The execution's test run configuration.
         """
         super(ExecutionResult, self).__init__()
-        self.sut_node = sut_node
+        self._config = execution
+        self._test_suites_with_cases = []
 
     def add_build_target(self, build_target: BuildTargetConfiguration) -> "BuildTargetResult":
-        """Add and return the inner result (build target).
+        """Add and return the child result (build target).
 
         Args:
             build_target: The build target's test run configuration.
@@ -334,9 +351,34 @@ def add_build_target(self, build_target: BuildTargetConfiguration) -> "BuildTarg
         Returns:
             The build target's result.
         """
-        build_target_result = BuildTargetResult(build_target)
-        self._inner_results.append(build_target_result)
-        return build_target_result
+        result = BuildTargetResult(
+            self._test_suites_with_cases,
+            build_target,
+        )
+        self.child_results.append(result)
+        return result
+
+    @property
+    def test_suites_with_cases(self) -> list[TestSuiteWithCases]:
+        """The test suites with test cases to be executed in this execution.
+
+        The test suites can only be assigned once.
+
+        Returns:
+            The list of test suites with test cases. If an error occurs between
+            the initialization of :class:`ExecutionResult` and assigning test cases to the instance,
+            return an empty list, representing that we don't know what to execute.
+        """
+        return self._test_suites_with_cases
+
+    @test_suites_with_cases.setter
+    def test_suites_with_cases(self, test_suites_with_cases: list[TestSuiteWithCases]) -> None:
+        if self._test_suites_with_cases:
+            raise ValueError(
+                "Attempted to assign test suites to an execution result "
+                "which already has test suites."
+            )
+        self._test_suites_with_cases = test_suites_with_cases
 
     def add_sut_info(self, sut_info: NodeInfo) -> None:
         """Add SUT information gathered at runtime.
@@ -348,6 +390,12 @@ def add_sut_info(self, sut_info: NodeInfo) -> None:
         self.sut_os_version = sut_info.os_version
         self.sut_kernel_version = sut_info.kernel_version
 
+    def _block_result(self) -> None:
+        r"""Mark the result as :attr:`~Result.BLOCK`\ed."""
+        for build_target in self._config.build_targets:
+            child_result = self.add_build_target(build_target)
+            child_result.update_setup(Result.BLOCK)
+
 
 class BuildTargetResult(BaseResult):
     """The build target specific result.
@@ -369,11 +417,17 @@ class BuildTargetResult(BaseResult):
     compiler: Compiler
     compiler_version: str | None
     dpdk_version: str | None
+    _test_suites_with_cases: list[TestSuiteWithCases]
 
-    def __init__(self, build_target: BuildTargetConfiguration):
-        """Extend the constructor with the `build_target`'s build target config.
+    def __init__(
+        self,
+        test_suites_with_cases: list[TestSuiteWithCases],
+        build_target: BuildTargetConfiguration,
+    ):
+        """Extend the constructor with the build target's config and ExecutionResult.
 
         Args:
+            test_suites_with_cases: The test suites with test cases to be run in this build target.
             build_target: The build target's test run configuration.
         """
         super(BuildTargetResult, self).__init__()
@@ -383,6 +437,23 @@ def __init__(self, build_target: BuildTargetConfiguration):
         self.compiler = build_target.compiler
         self.compiler_version = None
         self.dpdk_version = None
+        self._test_suites_with_cases = test_suites_with_cases
+
+    def add_test_suite(
+        self,
+        test_suite_with_cases: TestSuiteWithCases,
+    ) -> "TestSuiteResult":
+        """Add and return the child result (test suite).
+
+        Args:
+            test_suite_with_cases: The test suite with test cases.
+
+        Returns:
+            The test suite's result.
+        """
+        result = TestSuiteResult(test_suite_with_cases)
+        self.child_results.append(result)
+        return result
 
     def add_build_target_info(self, versions: BuildTargetInfo) -> None:
         """Add information about the build target gathered at runtime.
@@ -393,15 +464,11 @@ def add_build_target_info(self, versions: BuildTargetInfo) -> None:
         self.compiler_version = versions.compiler_version
         self.dpdk_version = versions.dpdk_version
 
-    def add_test_suite(self, test_suite_name: str) -> "TestSuiteResult":
-        """Add and return the inner result (test suite).
-
-        Returns:
-            The test suite's result.
-        """
-        test_suite_result = TestSuiteResult(test_suite_name)
-        self._inner_results.append(test_suite_result)
-        return test_suite_result
+    def _block_result(self) -> None:
+        r"""Mark the result as :attr:`~Result.BLOCK`\ed."""
+        for test_suite_with_cases in self._test_suites_with_cases:
+            child_result = self.add_test_suite(test_suite_with_cases)
+            child_result.update_setup(Result.BLOCK)
 
 
 class TestSuiteResult(BaseResult):
@@ -410,29 +477,42 @@ class TestSuiteResult(BaseResult):
     The internal list stores the results of all test cases in a given test suite.
 
     Attributes:
-        suite_name: The test suite name.
+        test_suite_name: The test suite name.
     """
 
-    suite_name: str
+    test_suite_name: str
+    _test_suite_with_cases: TestSuiteWithCases
+    _parent_result: BuildTargetResult
+    _child_configs: list[str]
 
-    def __init__(self, suite_name: str):
-        """Extend the constructor with `suite_name`.
+    def __init__(self, test_suite_with_cases: TestSuiteWithCases):
+        """Extend the constructor with test suite's config and BuildTargetResult.
 
         Args:
-            suite_name: The test suite's name.
+            test_suite_with_cases: The test suite with test cases.
         """
         super(TestSuiteResult, self).__init__()
-        self.suite_name = suite_name
+        self.test_suite_name = test_suite_with_cases.test_suite_class.__name__
+        self._test_suite_with_cases = test_suite_with_cases
 
     def add_test_case(self, test_case_name: str) -> "TestCaseResult":
-        """Add and return the inner result (test case).
+        """Add and return the child result (test case).
+
+        Args:
+            test_case_name: The name of the test case.
 
         Returns:
             The test case's result.
         """
-        test_case_result = TestCaseResult(test_case_name)
-        self._inner_results.append(test_case_result)
-        return test_case_result
+        result = TestCaseResult(test_case_name)
+        self.child_results.append(result)
+        return result
+
+    def _block_result(self) -> None:
+        r"""Mark the result as :attr:`~Result.BLOCK`\ed."""
+        for test_case_method in self._test_suite_with_cases.test_cases:
+            child_result = self.add_test_case(test_case_method.__name__)
+            child_result.update_setup(Result.BLOCK)
 
 
 class TestCaseResult(BaseResult, FixtureResult):
@@ -449,7 +529,7 @@ class TestCaseResult(BaseResult, FixtureResult):
     test_case_name: str
 
     def __init__(self, test_case_name: str):
-        """Extend the constructor with `test_case_name`.
+        """Extend the constructor with test case's name and TestSuiteResult.
 
         Args:
             test_case_name: The test case's name.
@@ -470,7 +550,7 @@ def update(self, result: Result, error: Exception | None = None) -> None:
         self.result = result
         self.error = error
 
-    def _get_inner_errors(self) -> list[Exception]:
+    def _get_child_errors(self) -> list[Exception]:
         if self.error:
             return [self.error]
         return []
@@ -486,6 +566,10 @@ def add_stats(self, statistics: "Statistics") -> None:
         """
         statistics += self.result
 
+    def _block_result(self) -> None:
+        r"""Mark the result as :attr:`~Result.BLOCK`\ed."""
+        self.update(Result.BLOCK)
+
     def __bool__(self) -> bool:
         """The test case passed only if setup, teardown and the test case itself passed."""
         return bool(self.setup_result) and bool(self.teardown_result) and bool(self.result)
-- 
2.34.1


^ permalink raw reply	[flat|nested] 28+ messages in thread

* [PATCH v2 6/7] dts: refactor logging configuration
  2024-02-06 14:57 ` [PATCH v2 0/7] " Juraj Linkeš
                     ` (4 preceding siblings ...)
  2024-02-06 14:57   ` [PATCH v2 5/7] dts: block all test cases when earlier setup fails Juraj Linkeš
@ 2024-02-06 14:57   ` Juraj Linkeš
  2024-02-12 16:45     ` Jeremy Spewock
  2024-02-06 14:57   ` [PATCH v2 7/7] dts: improve test suite and case filtering Juraj Linkeš
  6 siblings, 1 reply; 28+ messages in thread
From: Juraj Linkeš @ 2024-02-06 14:57 UTC (permalink / raw)
  To: thomas, Honnappa.Nagarahalli, jspewock, probb, paul.szczepanek,
	Luca.Vizzarro
  Cc: dev, Juraj Linkeš

Remove unused parts of the code and add useful features:
1. Add DTS execution stages such as execution and test suite to better
   identify where in the DTS lifecycle we are when investigating logs,
2. Logging to separate files in specific stages, which is mainly useful
   for having test suite logs in additional separate files.
3. Remove the dependence on the settings module which enhances the
   usefulness of the logger module, as it can now be imported in more
   modules.

The execution stages and the files to log to are the same for all DTS
loggers. To achieve this, we have one DTS root logger which should be
used for handling stage switching and all other loggers are children of
this DTS root logger. The DTS root logger is the one where we change the
behavior of all loggers (the stage and which files to log to) and the
child loggers just log messages under a different name.

Signed-off-by: Juraj Linkeš <juraj.linkes@pantheon.tech>
---
 dts/framework/logger.py                       | 235 +++++++++++-------
 dts/framework/remote_session/__init__.py      |   6 +-
 .../interactive_remote_session.py             |   6 +-
 .../remote_session/interactive_shell.py       |   6 +-
 .../remote_session/remote_session.py          |   8 +-
 dts/framework/runner.py                       |  19 +-
 dts/framework/test_result.py                  |   6 +-
 dts/framework/test_suite.py                   |   6 +-
 dts/framework/testbed_model/node.py           |  11 +-
 dts/framework/testbed_model/os_session.py     |   7 +-
 .../traffic_generator/traffic_generator.py    |   6 +-
 dts/main.py                                   |   1 -
 12 files changed, 183 insertions(+), 134 deletions(-)

diff --git a/dts/framework/logger.py b/dts/framework/logger.py
index cfa6e8cd72..568edad82d 100644
--- a/dts/framework/logger.py
+++ b/dts/framework/logger.py
@@ -5,141 +5,186 @@
 
 """DTS logger module.
 
-DTS framework and TestSuite logs are saved in different log files.
+The module provides several additional features:
+
+    * The storage of DTS execution stages,
+    * Logging to console, a human-readable log file and a machine-readable log file,
+    * Optional log files for specific stages.
 """
 
 import logging
-import os.path
-from typing import TypedDict
+from enum import auto
+from logging import FileHandler, StreamHandler
+from pathlib import Path
+from typing import ClassVar
 
-from .settings import SETTINGS
+from .utils import StrEnum
 
 date_fmt = "%Y/%m/%d %H:%M:%S"
-stream_fmt = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
+stream_fmt = "%(asctime)s - %(stage)s - %(name)s - %(levelname)s - %(message)s"
+dts_root_logger_name = "dts"
+
+
+class DtsStage(StrEnum):
+    """The DTS execution stage."""
 
+    #:
+    pre_execution = auto()
+    #:
+    execution = auto()
+    #:
+    build_target = auto()
+    #:
+    suite = auto()
+    #:
+    post_execution = auto()
 
-class DTSLOG(logging.LoggerAdapter):
-    """DTS logger adapter class for framework and testsuites.
 
-    The :option:`--verbose` command line argument and the :envvar:`DTS_VERBOSE` environment
-    variable control the verbosity of output. If enabled, all messages will be emitted to the
-    console.
+class DTSLogger(logging.Logger):
+    """The DTS logger class.
 
-    The :option:`--output` command line argument and the :envvar:`DTS_OUTPUT_DIR` environment
-    variable modify the directory where the logs will be stored.
+    The class extends the :class:`~logging.Logger` class to add the DTS execution stage information
+    to log records. The stage is common to all loggers, so it's stored in a class variable.
 
-    Attributes:
-        node: The additional identifier. Currently unused.
-        sh: The handler which emits logs to console.
-        fh: The handler which emits logs to a file.
-        verbose_fh: Just as fh, but logs with a different, more verbose, format.
+    Any time we switch to a new stage, we have the ability to log to an additional log file along
+    with a supplementary log file with machine-readable format. These two log files are used until
+    a new stage switch occurs. This is useful mainly for logging per test suite.
     """
 
-    _logger: logging.Logger
-    node: str
-    sh: logging.StreamHandler
-    fh: logging.FileHandler
-    verbose_fh: logging.FileHandler
+    _stage: ClassVar[DtsStage] = DtsStage.pre_execution
+    _extra_file_handlers: list[FileHandler] = []
 
-    def __init__(self, logger: logging.Logger, node: str = "suite"):
-        """Extend the constructor with additional handlers.
+    def __init__(self, *args, **kwargs):
+        """Extend the constructor with extra file handlers."""
+        self._extra_file_handlers = []
+        super().__init__(*args, **kwargs)
 
-        One handler logs to the console, the other one to a file, with either a regular or verbose
-        format.
+    def makeRecord(self, *args, **kwargs):
+        """Generates a record with additional stage information.
 
-        Args:
-            logger: The logger from which to create the logger adapter.
-            node: An additional identifier. Currently unused.
+        This is the default method for the :class:`~logging.Logger` class. We extend it
+        to add stage information to the record.
+
+        :meta private:
+
+        Returns:
+            record: The generated record with the stage information.
         """
-        self._logger = logger
-        # 1 means log everything, this will be used by file handlers if their level
-        # is not set
-        self._logger.setLevel(1)
+        record = super().makeRecord(*args, **kwargs)
+        record.stage = DTSLogger._stage
+        return record
+
+    def add_dts_root_logger_handlers(self, verbose: bool, output_dir: str) -> None:
+        """Add logger handlers to the DTS root logger.
+
+        This method should be called only on the DTS root logger.
+        The log records from child loggers will propagate to these handlers.
+
+        Three handlers are added:
 
-        self.node = node
+            * A console handler,
+            * A file handler,
+            * A supplementary file handler with machine-readable logs
+              containing more debug information.
 
-        # add handler to emit to stdout
-        sh = logging.StreamHandler()
+        All log messages will be logged to files. The log level of the console handler
+        is configurable with `verbose`.
+
+        Args:
+            verbose: If :data:`True`, log all messages to the console.
+                If :data:`False`, log to console with the :data:`logging.INFO` level.
+            output_dir: The directory where the log files will be located.
+                The names of the log files correspond to the name of the logger instance.
+        """
+        self.setLevel(1)
+
+        sh = StreamHandler()
         sh.setFormatter(logging.Formatter(stream_fmt, date_fmt))
-        sh.setLevel(logging.INFO)  # console handler default level
+        if not verbose:
+            sh.setLevel(logging.INFO)
+        self.addHandler(sh)
 
-        if SETTINGS.verbose is True:
-            sh.setLevel(logging.DEBUG)
+        self._add_file_handlers(Path(output_dir, self.name))
 
-        self._logger.addHandler(sh)
-        self.sh = sh
+    def set_stage(self, stage: DtsStage, log_file_path: Path | None = None) -> None:
+        """Set the DTS execution stage and optionally log to files.
 
-        # prepare the output folder
-        if not os.path.exists(SETTINGS.output_dir):
-            os.mkdir(SETTINGS.output_dir)
+        Set the DTS execution stage of the DTSLog class and optionally add
+        file handlers to the instance if the log file name is provided.
 
-        logging_path_prefix = os.path.join(SETTINGS.output_dir, node)
+        The file handlers log all messages. One is a regular human-readable log file and
+        the other one is a machine-readable log file with extra debug information.
 
-        fh = logging.FileHandler(f"{logging_path_prefix}.log")
-        fh.setFormatter(
-            logging.Formatter(
-                fmt="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
-                datefmt=date_fmt,
-            )
-        )
+        Args:
+            stage: The DTS stage to set.
+            log_file_path: An optional path of the log file to use. This should be a full path
+                (either relative or absolute) without suffix (which will be appended).
+        """
+        self._remove_extra_file_handlers()
+
+        if DTSLogger._stage != stage:
+            self.info(f"Moving from stage '{DTSLogger._stage}' to stage '{stage}'.")
+            DTSLogger._stage = stage
+
+        if log_file_path:
+            self._extra_file_handlers.extend(self._add_file_handlers(log_file_path))
+
+    def _add_file_handlers(self, log_file_path: Path) -> list[FileHandler]:
+        """Add file handlers to the DTS root logger.
+
+        Add two type of file handlers:
+
+            * A regular file handler with suffix ".log",
+            * A machine-readable file handler with suffix ".verbose.log".
+              This format provides extensive information for debugging and detailed analysis.
+
+        Args:
+            log_file_path: The full path to the log file without suffix.
+
+        Returns:
+            The newly created file handlers.
 
-        self._logger.addHandler(fh)
-        self.fh = fh
+        """
+        fh = FileHandler(f"{log_file_path}.log")
+        fh.setFormatter(logging.Formatter(stream_fmt, date_fmt))
+        self.addHandler(fh)
 
-        # This outputs EVERYTHING, intended for post-mortem debugging
-        # Also optimized for processing via AWK (awk -F '|' ...)
-        verbose_fh = logging.FileHandler(f"{logging_path_prefix}.verbose.log")
+        verbose_fh = FileHandler(f"{log_file_path}.verbose.log")
         verbose_fh.setFormatter(
             logging.Formatter(
-                fmt="%(asctime)s|%(name)s|%(levelname)s|%(pathname)s|%(lineno)d|"
+                "%(asctime)s|%(stage)s|%(name)s|%(levelname)s|%(pathname)s|%(lineno)d|"
                 "%(funcName)s|%(process)d|%(thread)d|%(threadName)s|%(message)s",
                 datefmt=date_fmt,
             )
         )
+        self.addHandler(verbose_fh)
 
-        self._logger.addHandler(verbose_fh)
-        self.verbose_fh = verbose_fh
-
-        super(DTSLOG, self).__init__(self._logger, dict(node=self.node))
-
-    def logger_exit(self) -> None:
-        """Remove the stream handler and the logfile handler."""
-        for handler in (self.sh, self.fh, self.verbose_fh):
-            handler.flush()
-            self._logger.removeHandler(handler)
-
-
-class _LoggerDictType(TypedDict):
-    logger: DTSLOG
-    name: str
-    node: str
-
+        return [fh, verbose_fh]
 
-# List for saving all loggers in use
-_Loggers: list[_LoggerDictType] = []
+    def _remove_extra_file_handlers(self) -> None:
+        """Remove any extra file handlers that have been added to the logger."""
+        if self._extra_file_handlers:
+            for extra_file_handler in self._extra_file_handlers:
+                self.removeHandler(extra_file_handler)
 
+            self._extra_file_handlers = []
 
-def getLogger(name: str, node: str = "suite") -> DTSLOG:
-    """Get DTS logger adapter identified by name and node.
 
-    An existing logger will be returned if one with the exact name and node already exists.
-    A new one will be created and stored otherwise.
+def get_dts_logger(name: str = None) -> DTSLogger:
+    """Return a DTS logger instance identified by `name`.
 
     Args:
-        name: The name of the logger.
-        node: An additional identifier for the logger.
+        name: If :data:`None`, return the DTS root logger.
+            If specified, return a child of the DTS root logger.
 
     Returns:
-        A logger uniquely identified by both name and node.
+         The DTS root logger or a child logger identified by `name`.
     """
-    global _Loggers
-    # return saved logger
-    logger: _LoggerDictType
-    for logger in _Loggers:
-        if logger["name"] == name and logger["node"] == node:
-            return logger["logger"]
-
-    # return new logger
-    dts_logger: DTSLOG = DTSLOG(logging.getLogger(name), node)
-    _Loggers.append({"logger": dts_logger, "name": name, "node": node})
-    return dts_logger
+    logging.setLoggerClass(DTSLogger)
+    if name:
+        name = f"{dts_root_logger_name}.{name}"
+    else:
+        name = dts_root_logger_name
+    logger = logging.getLogger(name)
+    logging.setLoggerClass(logging.Logger)
+    return logger  # type: ignore[return-value]
diff --git a/dts/framework/remote_session/__init__.py b/dts/framework/remote_session/__init__.py
index 51a01d6b5e..1910c81c3c 100644
--- a/dts/framework/remote_session/__init__.py
+++ b/dts/framework/remote_session/__init__.py
@@ -15,7 +15,7 @@
 # pylama:ignore=W0611
 
 from framework.config import NodeConfiguration
-from framework.logger import DTSLOG
+from framework.logger import DTSLogger
 
 from .interactive_remote_session import InteractiveRemoteSession
 from .interactive_shell import InteractiveShell
@@ -26,7 +26,7 @@
 
 
 def create_remote_session(
-    node_config: NodeConfiguration, name: str, logger: DTSLOG
+    node_config: NodeConfiguration, name: str, logger: DTSLogger
 ) -> RemoteSession:
     """Factory for non-interactive remote sessions.
 
@@ -45,7 +45,7 @@ def create_remote_session(
 
 
 def create_interactive_session(
-    node_config: NodeConfiguration, logger: DTSLOG
+    node_config: NodeConfiguration, logger: DTSLogger
 ) -> InteractiveRemoteSession:
     """Factory for interactive remote sessions.
 
diff --git a/dts/framework/remote_session/interactive_remote_session.py b/dts/framework/remote_session/interactive_remote_session.py
index 1cc82e3377..c50790db79 100644
--- a/dts/framework/remote_session/interactive_remote_session.py
+++ b/dts/framework/remote_session/interactive_remote_session.py
@@ -16,7 +16,7 @@
 
 from framework.config import NodeConfiguration
 from framework.exception import SSHConnectionError
-from framework.logger import DTSLOG
+from framework.logger import DTSLogger
 
 
 class InteractiveRemoteSession:
@@ -50,11 +50,11 @@ class InteractiveRemoteSession:
     username: str
     password: str
     session: SSHClient
-    _logger: DTSLOG
+    _logger: DTSLogger
     _node_config: NodeConfiguration
     _transport: Transport | None
 
-    def __init__(self, node_config: NodeConfiguration, logger: DTSLOG) -> None:
+    def __init__(self, node_config: NodeConfiguration, logger: DTSLogger) -> None:
         """Connect to the node during initialization.
 
         Args:
diff --git a/dts/framework/remote_session/interactive_shell.py b/dts/framework/remote_session/interactive_shell.py
index b158f963b6..5cfe202e15 100644
--- a/dts/framework/remote_session/interactive_shell.py
+++ b/dts/framework/remote_session/interactive_shell.py
@@ -20,7 +20,7 @@
 
 from paramiko import Channel, SSHClient, channel  # type: ignore[import]
 
-from framework.logger import DTSLOG
+from framework.logger import DTSLogger
 from framework.settings import SETTINGS
 
 
@@ -38,7 +38,7 @@ class InteractiveShell(ABC):
     _stdin: channel.ChannelStdinFile
     _stdout: channel.ChannelFile
     _ssh_channel: Channel
-    _logger: DTSLOG
+    _logger: DTSLogger
     _timeout: float
     _app_args: str
 
@@ -61,7 +61,7 @@ class InteractiveShell(ABC):
     def __init__(
         self,
         interactive_session: SSHClient,
-        logger: DTSLOG,
+        logger: DTSLogger,
         get_privileged_command: Callable[[str], str] | None,
         app_args: str = "",
         timeout: float = SETTINGS.timeout,
diff --git a/dts/framework/remote_session/remote_session.py b/dts/framework/remote_session/remote_session.py
index 2059f9a981..a69dc99400 100644
--- a/dts/framework/remote_session/remote_session.py
+++ b/dts/framework/remote_session/remote_session.py
@@ -9,14 +9,13 @@
 the structure of the result of a command execution.
 """
 
-
 import dataclasses
 from abc import ABC, abstractmethod
 from pathlib import PurePath
 
 from framework.config import NodeConfiguration
 from framework.exception import RemoteCommandExecutionError
-from framework.logger import DTSLOG
+from framework.logger import DTSLogger
 from framework.settings import SETTINGS
 
 
@@ -75,14 +74,14 @@ class RemoteSession(ABC):
     username: str
     password: str
     history: list[CommandResult]
-    _logger: DTSLOG
+    _logger: DTSLogger
     _node_config: NodeConfiguration
 
     def __init__(
         self,
         node_config: NodeConfiguration,
         session_name: str,
-        logger: DTSLOG,
+        logger: DTSLogger,
     ):
         """Connect to the node during initialization.
 
@@ -181,7 +180,6 @@ def close(self, force: bool = False) -> None:
         Args:
             force: Force the closure of the connection. This may not clean up all resources.
         """
-        self._logger.logger_exit()
         self._close(force)
 
     @abstractmethod
diff --git a/dts/framework/runner.py b/dts/framework/runner.py
index f58b0adc13..035e3368ef 100644
--- a/dts/framework/runner.py
+++ b/dts/framework/runner.py
@@ -19,9 +19,10 @@
 
 import importlib
 import inspect
-import logging
+import os
 import re
 import sys
+from pathlib import Path
 from types import MethodType
 from typing import Iterable
 
@@ -38,7 +39,7 @@
     SSHTimeoutError,
     TestCaseVerifyError,
 )
-from .logger import DTSLOG, getLogger
+from .logger import DTSLogger, DtsStage, get_dts_logger
 from .settings import SETTINGS
 from .test_result import (
     BuildTargetResult,
@@ -73,7 +74,7 @@ class DTSRunner:
     """
 
     _configuration: Configuration
-    _logger: DTSLOG
+    _logger: DTSLogger
     _result: DTSResult
     _test_suite_class_prefix: str
     _test_suite_module_prefix: str
@@ -83,7 +84,10 @@ class DTSRunner:
     def __init__(self):
         """Initialize the instance with configuration, logger, result and string constants."""
         self._configuration = load_config()
-        self._logger = getLogger("DTSRunner")
+        self._logger = get_dts_logger()
+        if not os.path.exists(SETTINGS.output_dir):
+            os.makedirs(SETTINGS.output_dir)
+        self._logger.add_dts_root_logger_handlers(SETTINGS.verbose, SETTINGS.output_dir)
         self._result = DTSResult(self._logger)
         self._test_suite_class_prefix = "Test"
         self._test_suite_module_prefix = "tests.TestSuite_"
@@ -137,6 +141,7 @@ def run(self):
 
             # for all Execution sections
             for execution in self._configuration.executions:
+                self._logger.set_stage(DtsStage.execution)
                 self._logger.info(
                     f"Running execution with SUT '{execution.system_under_test_node.name}'."
                 )
@@ -164,6 +169,7 @@ def run(self):
 
         finally:
             try:
+                self._logger.set_stage(DtsStage.post_execution)
                 for node in (sut_nodes | tg_nodes).values():
                     node.close()
                 self._result.update_teardown(Result.PASS)
@@ -419,6 +425,7 @@ def _run_execution(
 
         finally:
             try:
+                self._logger.set_stage(DtsStage.execution)
                 sut_node.tear_down_execution()
                 execution_result.update_teardown(Result.PASS)
             except Exception as e:
@@ -447,6 +454,7 @@ def _run_build_target(
                 with the current build target.
             test_suites_with_cases: The test suites with test cases to run.
         """
+        self._logger.set_stage(DtsStage.build_target)
         self._logger.info(f"Running build target '{build_target.name}'.")
 
         try:
@@ -463,6 +471,7 @@ def _run_build_target(
 
         finally:
             try:
+                self._logger.set_stage(DtsStage.build_target)
                 sut_node.tear_down_build_target()
                 build_target_result.update_teardown(Result.PASS)
             except Exception as e:
@@ -535,6 +544,7 @@ def _run_test_suite(
             BlockingTestSuiteError: If a blocking test suite fails.
         """
         test_suite_name = test_suite_with_cases.test_suite_class.__name__
+        self._logger.set_stage(DtsStage.suite, Path(SETTINGS.output_dir, test_suite_name))
         test_suite = test_suite_with_cases.test_suite_class(sut_node, tg_node)
         try:
             self._logger.info(f"Starting test suite setup: {test_suite_name}")
@@ -683,5 +693,4 @@ def _exit_dts(self) -> None:
         if self._logger:
             self._logger.info("DTS execution has ended.")
 
-        logging.shutdown()
         sys.exit(self._result.get_return_code())
diff --git a/dts/framework/test_result.py b/dts/framework/test_result.py
index eedb2d20ee..28f84fd793 100644
--- a/dts/framework/test_result.py
+++ b/dts/framework/test_result.py
@@ -42,7 +42,7 @@
     TestSuiteConfig,
 )
 from .exception import DTSError, ErrorSeverity
-from .logger import DTSLOG
+from .logger import DTSLogger
 from .settings import SETTINGS
 from .test_suite import TestSuite
 
@@ -237,13 +237,13 @@ class DTSResult(BaseResult):
     """
 
     dpdk_version: str | None
-    _logger: DTSLOG
+    _logger: DTSLogger
     _errors: list[Exception]
     _return_code: ErrorSeverity
     _stats_result: Union["Statistics", None]
     _stats_filename: str
 
-    def __init__(self, logger: DTSLOG):
+    def __init__(self, logger: DTSLogger):
         """Extend the constructor with top-level specifics.
 
         Args:
diff --git a/dts/framework/test_suite.py b/dts/framework/test_suite.py
index f9fe88093e..365f80e21a 100644
--- a/dts/framework/test_suite.py
+++ b/dts/framework/test_suite.py
@@ -21,7 +21,7 @@
 from scapy.packet import Packet, Padding  # type: ignore[import]
 
 from .exception import TestCaseVerifyError
-from .logger import DTSLOG, getLogger
+from .logger import DTSLogger, get_dts_logger
 from .testbed_model import Port, PortLink, SutNode, TGNode
 from .utils import get_packet_summaries
 
@@ -61,7 +61,7 @@ class TestSuite(object):
     #: Whether the test suite is blocking. A failure of a blocking test suite
     #: will block the execution of all subsequent test suites in the current build target.
     is_blocking: ClassVar[bool] = False
-    _logger: DTSLOG
+    _logger: DTSLogger
     _port_links: list[PortLink]
     _sut_port_ingress: Port
     _sut_port_egress: Port
@@ -88,7 +88,7 @@ def __init__(
         """
         self.sut_node = sut_node
         self.tg_node = tg_node
-        self._logger = getLogger(self.__class__.__name__)
+        self._logger = get_dts_logger(self.__class__.__name__)
         self._port_links = []
         self._process_links()
         self._sut_port_ingress, self._tg_port_egress = (
diff --git a/dts/framework/testbed_model/node.py b/dts/framework/testbed_model/node.py
index 1a55fadf78..74061f6262 100644
--- a/dts/framework/testbed_model/node.py
+++ b/dts/framework/testbed_model/node.py
@@ -23,7 +23,7 @@
     NodeConfiguration,
 )
 from framework.exception import ConfigurationError
-from framework.logger import DTSLOG, getLogger
+from framework.logger import DTSLogger, get_dts_logger
 from framework.settings import SETTINGS
 
 from .cpu import (
@@ -63,7 +63,7 @@ class Node(ABC):
     name: str
     lcores: list[LogicalCore]
     ports: list[Port]
-    _logger: DTSLOG
+    _logger: DTSLogger
     _other_sessions: list[OSSession]
     _execution_config: ExecutionConfiguration
     virtual_devices: list[VirtualDevice]
@@ -82,7 +82,7 @@ def __init__(self, node_config: NodeConfiguration):
         """
         self.config = node_config
         self.name = node_config.name
-        self._logger = getLogger(self.name)
+        self._logger = get_dts_logger(self.name)
         self.main_session = create_session(self.config, self.name, self._logger)
 
         self._logger.info(f"Connected to node: {self.name}")
@@ -189,7 +189,7 @@ def create_session(self, name: str) -> OSSession:
         connection = create_session(
             self.config,
             session_name,
-            getLogger(session_name, node=self.name),
+            get_dts_logger(session_name),
         )
         self._other_sessions.append(connection)
         return connection
@@ -299,7 +299,6 @@ def close(self) -> None:
             self.main_session.close()
         for session in self._other_sessions:
             session.close()
-        self._logger.logger_exit()
 
     @staticmethod
     def skip_setup(func: Callable[..., Any]) -> Callable[..., Any]:
@@ -314,7 +313,7 @@ def skip_setup(func: Callable[..., Any]) -> Callable[..., Any]:
             return func
 
 
-def create_session(node_config: NodeConfiguration, name: str, logger: DTSLOG) -> OSSession:
+def create_session(node_config: NodeConfiguration, name: str, logger: DTSLogger) -> OSSession:
     """Factory for OS-aware sessions.
 
     Args:
diff --git a/dts/framework/testbed_model/os_session.py b/dts/framework/testbed_model/os_session.py
index ac6bb5e112..6983aa4a77 100644
--- a/dts/framework/testbed_model/os_session.py
+++ b/dts/framework/testbed_model/os_session.py
@@ -21,7 +21,6 @@
     the :attr:`~.node.Node.main_session` translates that to ``rm -rf`` if the node's OS is Linux
     and other commands for other OSs. It also translates the path to match the underlying OS.
 """
-
 from abc import ABC, abstractmethod
 from collections.abc import Iterable
 from ipaddress import IPv4Interface, IPv6Interface
@@ -29,7 +28,7 @@
 from typing import Type, TypeVar, Union
 
 from framework.config import Architecture, NodeConfiguration, NodeInfo
-from framework.logger import DTSLOG
+from framework.logger import DTSLogger
 from framework.remote_session import (
     CommandResult,
     InteractiveRemoteSession,
@@ -62,7 +61,7 @@ class OSSession(ABC):
 
     _config: NodeConfiguration
     name: str
-    _logger: DTSLOG
+    _logger: DTSLogger
     remote_session: RemoteSession
     interactive_session: InteractiveRemoteSession
 
@@ -70,7 +69,7 @@ def __init__(
         self,
         node_config: NodeConfiguration,
         name: str,
-        logger: DTSLOG,
+        logger: DTSLogger,
     ):
         """Initialize the OS-aware session.
 
diff --git a/dts/framework/testbed_model/traffic_generator/traffic_generator.py b/dts/framework/testbed_model/traffic_generator/traffic_generator.py
index c49fbff488..d86d7fb532 100644
--- a/dts/framework/testbed_model/traffic_generator/traffic_generator.py
+++ b/dts/framework/testbed_model/traffic_generator/traffic_generator.py
@@ -13,7 +13,7 @@
 from scapy.packet import Packet  # type: ignore[import]
 
 from framework.config import TrafficGeneratorConfig
-from framework.logger import DTSLOG, getLogger
+from framework.logger import DTSLogger, get_dts_logger
 from framework.testbed_model.node import Node
 from framework.testbed_model.port import Port
 from framework.utils import get_packet_summaries
@@ -28,7 +28,7 @@ class TrafficGenerator(ABC):
 
     _config: TrafficGeneratorConfig
     _tg_node: Node
-    _logger: DTSLOG
+    _logger: DTSLogger
 
     def __init__(self, tg_node: Node, config: TrafficGeneratorConfig):
         """Initialize the traffic generator.
@@ -39,7 +39,7 @@ def __init__(self, tg_node: Node, config: TrafficGeneratorConfig):
         """
         self._config = config
         self._tg_node = tg_node
-        self._logger = getLogger(f"{self._tg_node.name} {self._config.traffic_generator_type}")
+        self._logger = get_dts_logger(f"{self._tg_node.name} {self._config.traffic_generator_type}")
 
     def send_packet(self, packet: Packet, port: Port) -> None:
         """Send `packet` and block until it is fully sent.
diff --git a/dts/main.py b/dts/main.py
index 1ffe8ff81f..d30c164b95 100755
--- a/dts/main.py
+++ b/dts/main.py
@@ -30,5 +30,4 @@ def main() -> None:
 
 # Main program begins here
 if __name__ == "__main__":
-    logging.raiseExceptions = True
     main()
-- 
2.34.1


^ permalink raw reply	[flat|nested] 28+ messages in thread

* [PATCH v2 7/7] dts: improve test suite and case filtering
  2024-02-06 14:57 ` [PATCH v2 0/7] " Juraj Linkeš
                     ` (5 preceding siblings ...)
  2024-02-06 14:57   ` [PATCH v2 6/7] dts: refactor logging configuration Juraj Linkeš
@ 2024-02-06 14:57   ` Juraj Linkeš
  6 siblings, 0 replies; 28+ messages in thread
From: Juraj Linkeš @ 2024-02-06 14:57 UTC (permalink / raw)
  To: thomas, Honnappa.Nagarahalli, jspewock, probb, paul.szczepanek,
	Luca.Vizzarro
  Cc: dev, Juraj Linkeš

The two places where we specify which test suite and test cases to run
are complimentary and not that intuitive to use. A unified way provides
a better user experience.

The syntax in test run configuration file has not changed, but the
environment variable and the command line arguments was changed to match
the config file syntax. This required changes in the settings module
which greatly simplified the parsing of the environment variables while
retaining the same functionality.

Signed-off-by: Juraj Linkeš <juraj.linkes@pantheon.tech>
---
 doc/guides/tools/dts.rst         |  14 ++-
 dts/framework/config/__init__.py |  12 +-
 dts/framework/runner.py          |  21 ++--
 dts/framework/settings.py        | 187 ++++++++++++++-----------------
 dts/framework/test_suite.py      |   2 +-
 dts/main.py                      |   2 -
 6 files changed, 116 insertions(+), 122 deletions(-)

diff --git a/doc/guides/tools/dts.rst b/doc/guides/tools/dts.rst
index f686ca487c..d1c3c2af7a 100644
--- a/doc/guides/tools/dts.rst
+++ b/doc/guides/tools/dts.rst
@@ -215,28 +215,30 @@ DTS is run with ``main.py`` located in the ``dts`` directory after entering Poet
 .. code-block:: console
 
    (dts-py3.10) $ ./main.py --help
-   usage: main.py [-h] [--config-file CONFIG_FILE] [--output-dir OUTPUT_DIR] [-t TIMEOUT] [-v] [-s] [--tarball TARBALL] [--compile-timeout COMPILE_TIMEOUT] [--test-cases TEST_CASES] [--re-run RE_RUN]
+   usage: main.py [-h] [--config-file CONFIG_FILE] [--output-dir OUTPUT_DIR] [-t TIMEOUT] [-v] [-s] [--tarball TARBALL] [--compile-timeout COMPILE_TIMEOUT] [--test-suite TEST_SUITE [TEST_CASES ...]] [--re-run RE_RUN]
 
    Run DPDK test suites. All options may be specified with the environment variables provided in brackets. Command line arguments have higher priority.
 
    options:
    -h, --help            show this help message and exit
    --config-file CONFIG_FILE
-                         [DTS_CFG_FILE] configuration file that describes the test cases, SUTs and targets. (default: ./conf.yaml)
+                         [DTS_CFG_FILE] The configuration file that describes the test cases, SUTs and targets. (default: ./conf.yaml)
    --output-dir OUTPUT_DIR, --output OUTPUT_DIR
                          [DTS_OUTPUT_DIR] Output directory where DTS logs and results are saved. (default: output)
    -t TIMEOUT, --timeout TIMEOUT
                          [DTS_TIMEOUT] The default timeout for all DTS operations except for compiling DPDK. (default: 15)
    -v, --verbose         [DTS_VERBOSE] Specify to enable verbose output, logging all messages to the console. (default: False)
-   -s, --skip-setup      [DTS_SKIP_SETUP] Specify to skip all setup steps on SUT and TG nodes. (default: None)
+   -s, --skip-setup      [DTS_SKIP_SETUP] Specify to skip all setup steps on SUT and TG nodes. (default: False)
    --tarball TARBALL, --snapshot TARBALL, --git-ref TARBALL
                          [DTS_DPDK_TARBALL] Path to DPDK source code tarball or a git commit ID, tag ID or tree ID to test. To test local changes, first commit them, then use the commit ID with this option. (default: dpdk.tar.xz)
    --compile-timeout COMPILE_TIMEOUT
                          [DTS_COMPILE_TIMEOUT] The timeout for compiling DPDK. (default: 1200)
-   --test-cases TEST_CASES
-                         [DTS_TESTCASES] Comma-separated list of test cases to execute. Unknown test cases will be silently ignored. (default: )
+   --test-suite TEST_SUITE [TEST_CASES ...]
+                         [DTS_TEST_SUITES] A list containing a test suite with test cases. The first parameter is the test suite name, and the rest are test case names, which are optional. May be specified multiple times. To specify multiple test suites in the environment
+                         variable, join the lists with a comma. Examples: --test-suite suite case case --test-suite suite case ... | DTS_TEST_SUITES='suite case case, suite case, ...' | --test-suite suite --test-suite suite case ... | DTS_TEST_SUITES='suite, suite case, ...'
+                         (default: [])
    --re-run RE_RUN, --re_run RE_RUN
-                         [DTS_RERUN] Re-run each test case the specified number of times if a test failure occurs (default: 0)
+                         [DTS_RERUN] Re-run each test case the specified number of times if a test failure occurs. (default: 0)
 
 
 The brackets contain the names of environment variables that set the same thing.
diff --git a/dts/framework/config/__init__.py b/dts/framework/config/__init__.py
index c6a93b3b89..4cb5c74059 100644
--- a/dts/framework/config/__init__.py
+++ b/dts/framework/config/__init__.py
@@ -35,9 +35,9 @@
 
 import json
 import os.path
-import pathlib
 from dataclasses import dataclass, fields
 from enum import auto, unique
+from pathlib import Path
 from typing import Union
 
 import warlock  # type: ignore[import]
@@ -53,7 +53,6 @@
     TrafficGeneratorConfigDict,
 )
 from framework.exception import ConfigurationError
-from framework.settings import SETTINGS
 from framework.utils import StrEnum
 
 
@@ -571,7 +570,7 @@ def from_dict(d: ConfigurationDict) -> "Configuration":
         return Configuration(executions=executions)
 
 
-def load_config() -> Configuration:
+def load_config(config_file_path: Path) -> Configuration:
     """Load DTS test run configuration from a file.
 
     Load the YAML test run configuration file
@@ -581,13 +580,16 @@ def load_config() -> Configuration:
     The YAML test run configuration file is specified in the :option:`--config-file` command line
     argument or the :envvar:`DTS_CFG_FILE` environment variable.
 
+    Args:
+        config_file_path: The path to the YAML test run configuration file.
+
     Returns:
         The parsed test run configuration.
     """
-    with open(SETTINGS.config_file_path, "r") as f:
+    with open(config_file_path, "r") as f:
         config_data = yaml.safe_load(f)
 
-    schema_path = os.path.join(pathlib.Path(__file__).parent.resolve(), "conf_yaml_schema.json")
+    schema_path = os.path.join(Path(__file__).parent.resolve(), "conf_yaml_schema.json")
 
     with open(schema_path, "r") as f:
         schema = json.load(f)
diff --git a/dts/framework/runner.py b/dts/framework/runner.py
index 035e3368ef..32c0698cd2 100644
--- a/dts/framework/runner.py
+++ b/dts/framework/runner.py
@@ -24,7 +24,7 @@
 import sys
 from pathlib import Path
 from types import MethodType
-from typing import Iterable
+from typing import Iterable, Sequence
 
 from .config import (
     BuildTargetConfiguration,
@@ -83,7 +83,7 @@ class DTSRunner:
 
     def __init__(self):
         """Initialize the instance with configuration, logger, result and string constants."""
-        self._configuration = load_config()
+        self._configuration = load_config(SETTINGS.config_file_path)
         self._logger = get_dts_logger()
         if not os.path.exists(SETTINGS.output_dir):
             os.makedirs(SETTINGS.output_dir)
@@ -129,7 +129,7 @@ def run(self):
             #. Execution teardown
 
         The test cases are filtered according to the specification in the test run configuration and
-        the :option:`--test-cases` command line argument or
+        the :option:`--test-suite` command line argument or
         the :envvar:`DTS_TESTCASES` environment variable.
         """
         sut_nodes: dict[str, SutNode] = {}
@@ -146,14 +146,17 @@ def run(self):
                     f"Running execution with SUT '{execution.system_under_test_node.name}'."
                 )
                 execution_result = self._result.add_execution(execution)
+                test_suite_configs = (
+                    SETTINGS.test_suites if SETTINGS.test_suites else execution.test_suites
+                )
                 try:
                     test_suites_with_cases = self._get_test_suites_with_cases(
-                        execution.test_suites, execution.func, execution.perf
+                        test_suite_configs, execution.func, execution.perf
                     )
                     execution_result.test_suites_with_cases = test_suites_with_cases
                 except Exception as e:
                     self._logger.exception(
-                        f"Invalid test suite configuration found: " f"{execution.test_suites}."
+                        f"Invalid test suite configuration found: " f"{test_suite_configs}."
                     )
                     execution_result.update_setup(Result.FAIL, e)
 
@@ -222,7 +225,7 @@ def _get_test_suites_with_cases(
             test_suite_class = self._get_test_suite_class(test_suite_config.test_suite)
             test_cases = []
             func_test_cases, perf_test_cases = self._filter_test_cases(
-                test_suite_class, set(test_suite_config.test_cases + SETTINGS.test_cases)
+                test_suite_class, test_suite_config.test_cases
             )
             if func:
                 test_cases.extend(func_test_cases)
@@ -295,7 +298,7 @@ def is_test_suite(object) -> bool:
         )
 
     def _filter_test_cases(
-        self, test_suite_class: type[TestSuite], test_cases_to_run: set[str]
+        self, test_suite_class: type[TestSuite], test_cases_to_run: Sequence[str]
     ) -> tuple[list[MethodType], list[MethodType]]:
         """Filter `test_cases_to_run` from `test_suite_class`.
 
@@ -324,7 +327,9 @@ def _filter_test_cases(
                 (name, method) for name, method in name_method_tuples if name in test_cases_to_run
             ]
             if len(name_method_tuples) < len(test_cases_to_run):
-                missing_test_cases = test_cases_to_run - {name for name, _ in name_method_tuples}
+                missing_test_cases = set(test_cases_to_run) - {
+                    name for name, _ in name_method_tuples
+                }
                 raise ConfigurationError(
                     f"Test cases {missing_test_cases} not found among methods "
                     f"of {test_suite_class.__name__}."
diff --git a/dts/framework/settings.py b/dts/framework/settings.py
index 2b8bfbe0ed..688e8679a7 100644
--- a/dts/framework/settings.py
+++ b/dts/framework/settings.py
@@ -48,10 +48,11 @@
 
     The path to a DPDK tarball, git commit ID, tag ID or tree ID to test.
 
-.. option:: --test-cases
-.. envvar:: DTS_TESTCASES
+.. option:: --test-suite
+.. envvar:: DTS_TEST_SUITES
 
-    A comma-separated list of test cases to execute. Unknown test cases will be silently ignored.
+        A test suite with test cases which may be specified multiple times.
+        In the environment variable, the suites are joined with a comma.
 
 .. option:: --re-run, --re_run
 .. envvar:: DTS_RERUN
@@ -71,83 +72,13 @@
 
 import argparse
 import os
-from collections.abc import Callable, Iterable, Sequence
 from dataclasses import dataclass, field
 from pathlib import Path
-from typing import Any, TypeVar
+from typing import Any
 
+from .config import TestSuiteConfig
 from .utils import DPDKGitTarball
 
-_T = TypeVar("_T")
-
-
-def _env_arg(env_var: str) -> Any:
-    """A helper method augmenting the argparse Action with environment variables.
-
-    If the supplied environment variable is defined, then the default value
-    of the argument is modified. This satisfies the priority order of
-    command line argument > environment variable > default value.
-
-    Arguments with no values (flags) should be defined using the const keyword argument
-    (True or False). When the argument is specified, it will be set to const, if not specified,
-    the default will be stored (possibly modified by the corresponding environment variable).
-
-    Other arguments work the same as default argparse arguments, that is using
-    the default 'store' action.
-
-    Returns:
-          The modified argparse.Action.
-    """
-
-    class _EnvironmentArgument(argparse.Action):
-        def __init__(
-            self,
-            option_strings: Sequence[str],
-            dest: str,
-            nargs: str | int | None = None,
-            const: bool | None = None,
-            default: Any = None,
-            type: Callable[[str], _T | argparse.FileType | None] = None,
-            choices: Iterable[_T] | None = None,
-            required: bool = False,
-            help: str | None = None,
-            metavar: str | tuple[str, ...] | None = None,
-        ) -> None:
-            env_var_value = os.environ.get(env_var)
-            default = env_var_value or default
-            if const is not None:
-                nargs = 0
-                default = const if env_var_value else default
-                type = None
-                choices = None
-                metavar = None
-            super(_EnvironmentArgument, self).__init__(
-                option_strings,
-                dest,
-                nargs=nargs,
-                const=const,
-                default=default,
-                type=type,
-                choices=choices,
-                required=required,
-                help=help,
-                metavar=metavar,
-            )
-
-        def __call__(
-            self,
-            parser: argparse.ArgumentParser,
-            namespace: argparse.Namespace,
-            values: Any,
-            option_string: str = None,
-        ) -> None:
-            if self.const is not None:
-                setattr(namespace, self.dest, self.const)
-            else:
-                setattr(namespace, self.dest, values)
-
-    return _EnvironmentArgument
-
 
 @dataclass(slots=True)
 class Settings:
@@ -171,7 +102,7 @@ class Settings:
     #:
     compile_timeout: float = 1200
     #:
-    test_cases: list[str] = field(default_factory=list)
+    test_suites: list[TestSuiteConfig] = field(default_factory=list)
     #:
     re_run: int = 0
 
@@ -180,6 +111,31 @@ class Settings:
 
 
 def _get_parser() -> argparse.ArgumentParser:
+    """Create the argument parser for DTS.
+
+    Command line options take precedence over environment variables, which in turn take precedence
+    over default values.
+
+    Returns:
+        argparse.ArgumentParser: The configured argument parser with defined options.
+    """
+
+    def env_arg(env_var: str, default: Any) -> Any:
+        """A helper function augmenting the argparse with environment variables.
+
+        If the supplied environment variable is defined, then the default value
+        of the argument is modified. This satisfies the priority order of
+        command line argument > environment variable > default value.
+
+        Args:
+            env_var: Environment variable name.
+            default: Default value.
+
+        Returns:
+            Environment variable or default value.
+        """
+        return os.environ.get(env_var) or default
+
     parser = argparse.ArgumentParser(
         description="Run DPDK test suites. All options may be specified with the environment "
         "variables provided in brackets. Command line arguments have higher priority.",
@@ -188,25 +144,23 @@ def _get_parser() -> argparse.ArgumentParser:
 
     parser.add_argument(
         "--config-file",
-        action=_env_arg("DTS_CFG_FILE"),
-        default=SETTINGS.config_file_path,
+        default=env_arg("DTS_CFG_FILE", SETTINGS.config_file_path),
         type=Path,
-        help="[DTS_CFG_FILE] configuration file that describes the test cases, SUTs and targets.",
+        help="[DTS_CFG_FILE] The configuration file that describes the test cases, "
+        "SUTs and targets.",
     )
 
     parser.add_argument(
         "--output-dir",
         "--output",
-        action=_env_arg("DTS_OUTPUT_DIR"),
-        default=SETTINGS.output_dir,
+        default=env_arg("DTS_OUTPUT_DIR", SETTINGS.output_dir),
         help="[DTS_OUTPUT_DIR] Output directory where DTS logs and results are saved.",
     )
 
     parser.add_argument(
         "-t",
         "--timeout",
-        action=_env_arg("DTS_TIMEOUT"),
-        default=SETTINGS.timeout,
+        default=env_arg("DTS_TIMEOUT", SETTINGS.timeout),
         type=float,
         help="[DTS_TIMEOUT] The default timeout for all DTS operations except for compiling DPDK.",
     )
@@ -214,9 +168,8 @@ def _get_parser() -> argparse.ArgumentParser:
     parser.add_argument(
         "-v",
         "--verbose",
-        action=_env_arg("DTS_VERBOSE"),
-        default=SETTINGS.verbose,
-        const=True,
+        action="store_true",
+        default=env_arg("DTS_VERBOSE", SETTINGS.verbose),
         help="[DTS_VERBOSE] Specify to enable verbose output, logging all messages "
         "to the console.",
     )
@@ -224,8 +177,8 @@ def _get_parser() -> argparse.ArgumentParser:
     parser.add_argument(
         "-s",
         "--skip-setup",
-        action=_env_arg("DTS_SKIP_SETUP"),
-        const=True,
+        action="store_true",
+        default=env_arg("DTS_SKIP_SETUP", SETTINGS.skip_setup),
         help="[DTS_SKIP_SETUP] Specify to skip all setup steps on SUT and TG nodes.",
     )
 
@@ -233,8 +186,7 @@ def _get_parser() -> argparse.ArgumentParser:
         "--tarball",
         "--snapshot",
         "--git-ref",
-        action=_env_arg("DTS_DPDK_TARBALL"),
-        default=SETTINGS.dpdk_tarball_path,
+        default=env_arg("DTS_DPDK_TARBALL", SETTINGS.dpdk_tarball_path),
         type=Path,
         help="[DTS_DPDK_TARBALL] Path to DPDK source code tarball or a git commit ID, "
         "tag ID or tree ID to test. To test local changes, first commit them, "
@@ -243,36 +195,71 @@ def _get_parser() -> argparse.ArgumentParser:
 
     parser.add_argument(
         "--compile-timeout",
-        action=_env_arg("DTS_COMPILE_TIMEOUT"),
-        default=SETTINGS.compile_timeout,
+        default=env_arg("DTS_COMPILE_TIMEOUT", SETTINGS.compile_timeout),
         type=float,
         help="[DTS_COMPILE_TIMEOUT] The timeout for compiling DPDK.",
     )
 
     parser.add_argument(
-        "--test-cases",
-        action=_env_arg("DTS_TESTCASES"),
-        default="",
-        help="[DTS_TESTCASES] Comma-separated list of test cases to execute.",
+        "--test-suite",
+        action="append",
+        nargs="+",
+        metavar=("TEST_SUITE", "TEST_CASES"),
+        default=env_arg("DTS_TEST_SUITES", SETTINGS.test_suites),
+        help="[DTS_TEST_SUITES] A list containing a test suite with test cases. "
+        "The first parameter is the test suite name, and the rest are test case names, "
+        "which are optional. May be specified multiple times. To specify multiple test suites in "
+        "the environment variable, join the lists with a comma. "
+        "Examples: "
+        "--test-suite suite case case --test-suite suite case ... | "
+        "DTS_TEST_SUITES='suite case case, suite case, ...' | "
+        "--test-suite suite --test-suite suite case ... | "
+        "DTS_TEST_SUITES='suite, suite case, ...'",
     )
 
     parser.add_argument(
         "--re-run",
         "--re_run",
-        action=_env_arg("DTS_RERUN"),
-        default=SETTINGS.re_run,
+        default=env_arg("DTS_RERUN", SETTINGS.re_run),
         type=int,
         help="[DTS_RERUN] Re-run each test case the specified number of times "
-        "if a test failure occurs",
+        "if a test failure occurs.",
     )
 
     return parser
 
 
+def _process_test_suites(args: str | list[list[str]]) -> list[TestSuiteConfig]:
+    """Process the given argument to a list of :class:`TestSuiteConfig` to execute.
+
+    Args:
+        args: The arguments to process. The args is a string from an environment variable
+              or a list of from the user input containing tests suites with tests cases,
+              each of which is a list of [test_suite, test_case, test_case, ...].
+
+    Returns:
+        A list of test suite configurations to execute.
+    """
+    if isinstance(args, str):
+        # Environment variable in the form of "suite case case, suite case, suite, ..."
+        args = [suite_with_cases.split() for suite_with_cases in args.split(",")]
+
+    test_suites_to_run = []
+    for suite_with_cases in args:
+        test_suites_to_run.append(
+            TestSuiteConfig(test_suite=suite_with_cases[0], test_cases=suite_with_cases[1:])
+        )
+
+    return test_suites_to_run
+
+
 def get_settings() -> Settings:
     """Create new settings with inputs from the user.
 
     The inputs are taken from the command line and from environment variables.
+
+    Returns:
+        The new settings object.
     """
     parsed_args = _get_parser().parse_args()
     return Settings(
@@ -287,6 +274,6 @@ def get_settings() -> Settings:
             else Path(parsed_args.tarball)
         ),
         compile_timeout=parsed_args.compile_timeout,
-        test_cases=(parsed_args.test_cases.split(",") if parsed_args.test_cases else []),
+        test_suites=_process_test_suites(parsed_args.test_suite),
         re_run=parsed_args.re_run,
     )
diff --git a/dts/framework/test_suite.py b/dts/framework/test_suite.py
index 365f80e21a..1957ea7328 100644
--- a/dts/framework/test_suite.py
+++ b/dts/framework/test_suite.py
@@ -40,7 +40,7 @@ class TestSuite(object):
     and functional test cases (all other test cases).
 
     By default, all test cases will be executed. A list of testcase names may be specified
-    in the YAML test run configuration file and in the :option:`--test-cases` command line argument
+    in the YAML test run configuration file and in the :option:`--test-suite` command line argument
     or in the :envvar:`DTS_TESTCASES` environment variable to filter which test cases to run.
     The union of both lists will be used. Any unknown test cases from the latter lists
     will be silently ignored.
diff --git a/dts/main.py b/dts/main.py
index d30c164b95..fa878cc16e 100755
--- a/dts/main.py
+++ b/dts/main.py
@@ -6,8 +6,6 @@
 
 """The DTS executable."""
 
-import logging
-
 from framework import settings
 
 
-- 
2.34.1


^ permalink raw reply	[flat|nested] 28+ messages in thread

* Re: [PATCH v2 3/7] dts: filter test suites in executions
  2024-02-06 14:57   ` [PATCH v2 3/7] dts: filter test suites in executions Juraj Linkeš
@ 2024-02-12 16:44     ` Jeremy Spewock
  2024-02-14  9:55       ` Juraj Linkeš
  0 siblings, 1 reply; 28+ messages in thread
From: Jeremy Spewock @ 2024-02-12 16:44 UTC (permalink / raw)
  To: Juraj Linkeš
  Cc: thomas, Honnappa.Nagarahalli, probb, paul.szczepanek, Luca.Vizzarro, dev

On Tue, Feb 6, 2024 at 9:57 AM Juraj Linkeš <juraj.linkes@pantheon.tech> wrote:
>
> We're currently filtering which test cases to run after some setup
> steps, such as DPDK build, have already been taken. This prohibits us to
> mark the test suites and cases that were supposed to be run as blocked
> when an earlier setup fails, as that information is not available at
> that time.
>
> To remedy this, move the filtering to the beginning of each execution.
> This is the first action taken in each execution and if we can't filter
> the test cases, such as due to invalid inputs, we abort the whole
> execution. No test suites nor cases will be marked as blocked as we
> don't know which were supposed to be run.
>
> On top of that, the filtering takes place in the TestSuite class, which
> should only concern itself with test suite and test case logic, not the
> processing behind the scenes. The logic has been moved to DTSRunner
> which should do all the processing needed to run test suites.
>
> The filtering itself introduces a few changes/assumptions which are more
> sensible than before:
> 1. Assumption: There is just one TestSuite child class in each test
>    suite module. This was an implicit assumption before as we couldn't
>    specify the TestSuite classes in the test run configuration, just the
>    modules. The name of the TestSuite child class starts with "Test" and
>    then corresponds to the name of the module with CamelCase naming.
> 2. Unknown test cases specified both in the test run configuration and
>    the environment variable/command line argument are no longer silently
>    ignored. This is a quality of life improvement for users, as they
>    could easily be not aware of the silent ignoration.
>
> Also, a change in the code results in pycodestyle warning and error:
> [E] E203 whitespace before ':'
> [W] W503 line break before binary operator
>
> These two are not PEP8 compliant, so they're disabled.
>
> Signed-off-by: Juraj Linkeš <juraj.linkes@pantheon.tech>
> ---
>  dts/framework/config/__init__.py           |  24 +-
>  dts/framework/config/conf_yaml_schema.json |   2 +-
>  dts/framework/runner.py                    | 426 +++++++++++++++------
>  dts/framework/settings.py                  |   3 +-
>  dts/framework/test_result.py               |  34 ++
>  dts/framework/test_suite.py                |  85 +---
>  dts/pyproject.toml                         |   3 +
>  dts/tests/TestSuite_smoke_tests.py         |   2 +-
>  8 files changed, 382 insertions(+), 197 deletions(-)
>
> diff --git a/dts/framework/config/__init__.py b/dts/framework/config/__init__.py
> index 62eded7f04..c6a93b3b89 100644
> --- a/dts/framework/config/__init__.py
> +++ b/dts/framework/config/__init__.py
> @@ -36,7 +36,7 @@
>  import json
>  import os.path
>  import pathlib
> -from dataclasses import dataclass
> +from dataclasses import dataclass, fields
>  from enum import auto, unique
>  from typing import Union
>
> @@ -506,6 +506,28 @@ def from_dict(
>              vdevs=vdevs,
>          )
>
> +    def copy_and_modify(self, **kwargs) -> "ExecutionConfiguration":
> +        """Create a shallow copy with any of the fields modified.
> +
> +        The only new data are those passed to this method.
> +        The rest are copied from the object's fields calling the method.
> +
> +        Args:
> +            **kwargs: The names and types of keyword arguments are defined
> +                by the fields of the :class:`ExecutionConfiguration` class.
> +
> +        Returns:
> +            The copied and modified execution configuration.
> +        """
> +        new_config = {}
> +        for field in fields(self):
> +            if field.name in kwargs:
> +                new_config[field.name] = kwargs[field.name]
> +            else:
> +                new_config[field.name] = getattr(self, field.name)
> +
> +        return ExecutionConfiguration(**new_config)
> +
>
>  @dataclass(slots=True, frozen=True)
>  class Configuration:
> diff --git a/dts/framework/config/conf_yaml_schema.json b/dts/framework/config/conf_yaml_schema.json
> index 84e45fe3c2..051b079fe4 100644
> --- a/dts/framework/config/conf_yaml_schema.json
> +++ b/dts/framework/config/conf_yaml_schema.json
> @@ -197,7 +197,7 @@
>          },
>          "cases": {
>            "type": "array",
> -          "description": "If specified, only this subset of test suite's test cases will be run. Unknown test cases will be silently ignored.",
> +          "description": "If specified, only this subset of test suite's test cases will be run.",
>            "items": {
>              "type": "string"
>            },
> diff --git a/dts/framework/runner.py b/dts/framework/runner.py
> index 933685d638..3e95cf9e26 100644
> --- a/dts/framework/runner.py
> +++ b/dts/framework/runner.py
> @@ -17,17 +17,27 @@
>  and the test case stage runs test cases individually.
>  """
>
> +import importlib
> +import inspect
>  import logging
> +import re
>  import sys
>  from types import MethodType
> +from typing import Iterable
>
>  from .config import (
>      BuildTargetConfiguration,
> +    Configuration,
>      ExecutionConfiguration,
>      TestSuiteConfig,
>      load_config,
>  )
> -from .exception import BlockingTestSuiteError, SSHTimeoutError, TestCaseVerifyError
> +from .exception import (
> +    BlockingTestSuiteError,
> +    ConfigurationError,
> +    SSHTimeoutError,
> +    TestCaseVerifyError,
> +)
>  from .logger import DTSLOG, getLogger
>  from .settings import SETTINGS
>  from .test_result import (
> @@ -37,8 +47,9 @@
>      Result,
>      TestCaseResult,
>      TestSuiteResult,
> +    TestSuiteWithCases,
>  )
> -from .test_suite import TestSuite, get_test_suites
> +from .test_suite import TestSuite
>  from .testbed_model import SutNode, TGNode
>
>
> @@ -59,13 +70,23 @@ class DTSRunner:
>          given execution, the next execution begins.
>      """
>
> +    _configuration: Configuration
>      _logger: DTSLOG
>      _result: DTSResult
> +    _test_suite_class_prefix: str
> +    _test_suite_module_prefix: str
> +    _func_test_case_regex: str
> +    _perf_test_case_regex: str
>
>      def __init__(self):
> -        """Initialize the instance with logger and result."""
> +        """Initialize the instance with configuration, logger, result and string constants."""
> +        self._configuration = load_config()
>          self._logger = getLogger("DTSRunner")
>          self._result = DTSResult(self._logger)
> +        self._test_suite_class_prefix = "Test"
> +        self._test_suite_module_prefix = "tests.TestSuite_"
> +        self._func_test_case_regex = r"test_(?!perf_)"
> +        self._perf_test_case_regex = r"test_perf_"
>
>      def run(self):
>          """Run all build targets in all executions from the test run configuration.
> @@ -106,29 +127,28 @@ def run(self):
>          try:
>              # check the python version of the server that runs dts
>              self._check_dts_python_version()
> +            self._result.update_setup(Result.PASS)
>
>              # for all Execution sections
> -            for execution in load_config().executions:
> -                sut_node = sut_nodes.get(execution.system_under_test_node.name)
> -                tg_node = tg_nodes.get(execution.traffic_generator_node.name)
> -
> +            for execution in self._configuration.executions:
> +                self._logger.info(
> +                    f"Running execution with SUT '{execution.system_under_test_node.name}'."
> +                )
> +                execution_result = self._result.add_execution(execution.system_under_test_node)
>                  try:
> -                    if not sut_node:
> -                        sut_node = SutNode(execution.system_under_test_node)
> -                        sut_nodes[sut_node.name] = sut_node
> -                    if not tg_node:
> -                        tg_node = TGNode(execution.traffic_generator_node)
> -                        tg_nodes[tg_node.name] = tg_node
> -                    self._result.update_setup(Result.PASS)
> +                    test_suites_with_cases = self._get_test_suites_with_cases(
> +                        execution.test_suites, execution.func, execution.perf
> +                    )
>                  except Exception as e:
> -                    failed_node = execution.system_under_test_node.name
> -                    if sut_node:
> -                        failed_node = execution.traffic_generator_node.name
> -                    self._logger.exception(f"The Creation of node {failed_node} failed.")
> -                    self._result.update_setup(Result.FAIL, e)
> +                    self._logger.exception(
> +                        f"Invalid test suite configuration found: " f"{execution.test_suites}."
> +                    )
> +                    execution_result.update_setup(Result.FAIL, e)
>
>                  else:
> -                    self._run_execution(sut_node, tg_node, execution)
> +                    self._connect_nodes_and_run_execution(
> +                        sut_nodes, tg_nodes, execution, execution_result, test_suites_with_cases
> +                    )
>
>          except Exception as e:
>              self._logger.exception("An unexpected error has occurred.")
> @@ -163,11 +183,204 @@ def _check_dts_python_version(self) -> None:
>              )
>              self._logger.warning("Please use Python >= 3.10 instead.")
>
> +    def _get_test_suites_with_cases(
> +        self,
> +        test_suite_configs: list[TestSuiteConfig],
> +        func: bool,
> +        perf: bool,
> +    ) -> list[TestSuiteWithCases]:
> +        """Test suites with test cases discovery.
> +
> +        The test suites with test cases defined in the user configuration are discovered
> +        and stored for future use so that we don't import the modules twice and so that
> +        the list of test suites with test cases is available for recording right away.
> +
> +        Args:
> +            test_suite_configs: Test suite configurations.
> +            func: Whether to include functional test cases in the final list.
> +            perf: Whether to include performance test cases in the final list.
> +
> +        Returns:
> +            The discovered test suites, each with test cases.
> +        """
> +        test_suites_with_cases = []
> +
> +        for test_suite_config in test_suite_configs:
> +            test_suite_class = self._get_test_suite_class(test_suite_config.test_suite)
> +            test_cases = []
> +            func_test_cases, perf_test_cases = self._filter_test_cases(
> +                test_suite_class, set(test_suite_config.test_cases + SETTINGS.test_cases)
> +            )
> +            if func:
> +                test_cases.extend(func_test_cases)
> +            if perf:
> +                test_cases.extend(perf_test_cases)
> +
> +            test_suites_with_cases.append(
> +                TestSuiteWithCases(test_suite_class=test_suite_class, test_cases=test_cases)
> +            )
> +
> +        return test_suites_with_cases
> +
> +    def _get_test_suite_class(self, test_suite_name: str) -> type[TestSuite]:
> +        """Find the :class:`TestSuite` class with `test_suite_name` in the corresponding module.
> +
> +        The method assumes that the :class:`TestSuite` class starts
> +        with `self._test_suite_class_prefix`,
> +        continuing with `test_suite_name` with CamelCase convention.
> +        It also assumes there's only one test suite in each module and the module name
> +        is `test_suite_name` prefixed with `self._test_suite_module_prefix`.
> +
> +        The CamelCase convention is not tested, only lowercase strings are compared.
> +
> +        Args:
> +            test_suite_name: The name of the test suite to find.
> +
> +        Returns:
> +            The found test suite.
> +
> +        Raises:
> +            ConfigurationError: If the corresponding module is not found or
> +                a valid :class:`TestSuite` is not found in the module.
> +        """
> +
> +        def is_test_suite(object) -> bool:
> +            """Check whether `object` is a :class:`TestSuite`.
> +
> +            The `object` is a subclass of :class:`TestSuite`, but not :class:`TestSuite` itself.
> +
> +            Args:
> +                object: The object to be checked.
> +
> +            Returns:
> +                :data:`True` if `object` is a subclass of `TestSuite`.
> +            """
> +            try:
> +                if issubclass(object, TestSuite) and object is not TestSuite:
> +                    return True
> +            except TypeError:
> +                return False
> +            return False
> +
> +        testsuite_module_path = f"{self._test_suite_module_prefix}{test_suite_name}"
> +        try:
> +            test_suite_module = importlib.import_module(testsuite_module_path)
> +        except ModuleNotFoundError as e:
> +            raise ConfigurationError(
> +                f"Test suite module '{testsuite_module_path}' not found."
> +            ) from e
> +
> +        lowercase_suite_name = test_suite_name.replace("_", "").lower()
> +        for class_name, class_obj in inspect.getmembers(test_suite_module, is_test_suite):
> +            if (
> +                class_name.startswith(self._test_suite_class_prefix)
> +                and lowercase_suite_name == class_name[len(self._test_suite_class_prefix) :].lower()
> +            ):

Would it be simpler to instead just make lowercase_suite_name =
f"{self._test_suite_class_prefix}{test_suite_name.replace("_",
"").lower()}" so that you can just directly compare class_name ==
lowercase_suite_name? Both ways should have the exact same result of
course so it isn't important, I was just curious.

> +                return class_obj
> +        raise ConfigurationError(
> +            f"Couldn't find any valid test suites in {test_suite_module.__name__}."
> +        )
> +
> +    def _filter_test_cases(
> +        self, test_suite_class: type[TestSuite], test_cases_to_run: set[str]
> +    ) -> tuple[list[MethodType], list[MethodType]]:
> +        """Filter `test_cases_to_run` from `test_suite_class`.
> +
> +        There are two rounds of filtering if `test_cases_to_run` is not empty.
> +        The first filters `test_cases_to_run` from all methods of `test_suite_class`.
> +        Then the methods are separated into functional and performance test cases.
> +        If a method doesn't match neither the functional nor performance name prefix, it's an error.

I think this is a double negative but could be either "if a method
doesn't match either ... or ..." or "if a method matches neither ...
nor ...". I have a small preference to the second of the two options
though because the "neither" makes the negative more clear in my mind.

> +
> +        Args:
> +            test_suite_class: The class of the test suite.
> +            test_cases_to_run: Test case names to filter from `test_suite_class`.
> +                If empty, return all matching test cases.
> +
> +        Returns:
> +            A list of test case methods that should be executed.
> +
> +        Raises:
> +            ConfigurationError: If a test case from `test_cases_to_run` is not found
> +                or it doesn't match either the functional nor performance name prefix.
> +        """
> +        func_test_cases = []
> +        perf_test_cases = []
> +        name_method_tuples = inspect.getmembers(test_suite_class, inspect.isfunction)
> +        if test_cases_to_run:
> +            name_method_tuples = [
> +                (name, method) for name, method in name_method_tuples if name in test_cases_to_run
> +            ]
> +            if len(name_method_tuples) < len(test_cases_to_run):
> +                missing_test_cases = test_cases_to_run - {name for name, _ in name_method_tuples}
> +                raise ConfigurationError(
> +                    f"Test cases {missing_test_cases} not found among methods "
> +                    f"of {test_suite_class.__name__}."
> +                )
> +
> +        for test_case_name, test_case_method in name_method_tuples:
> +            if re.match(self._func_test_case_regex, test_case_name):
> +                func_test_cases.append(test_case_method)
> +            elif re.match(self._perf_test_case_regex, test_case_name):
> +                perf_test_cases.append(test_case_method)
> +            elif test_cases_to_run:
> +                raise ConfigurationError(
> +                    f"Method '{test_case_name}' doesn't match neither "
> +                    f"a functional nor a performance test case name."

Same thing here with the double negative.



> +                )
> +
> +        return func_test_cases, perf_test_cases
> +
> +    def _connect_nodes_and_run_execution(
> +        self,
> +        sut_nodes: dict[str, SutNode],
> +        tg_nodes: dict[str, TGNode],
> +        execution: ExecutionConfiguration,
> +        execution_result: ExecutionResult,
> +        test_suites_with_cases: Iterable[TestSuiteWithCases],
> +    ) -> None:
> +        """Connect nodes, then continue to run the given execution.
> +
> +        Connect the :class:`SutNode` and the :class:`TGNode` of this `execution`.
> +        If either has already been connected, it's going to be in either `sut_nodes` or `tg_nodes`,
> +        respectively.
> +        If not, connect and add the node to the respective `sut_nodes` or `tg_nodes` :class:`dict`.
> +
> +        Args:
> +            sut_nodes: A dictionary storing connected/to be connected SUT nodes.
> +            tg_nodes: A dictionary storing connected/to be connected TG nodes.
> +            execution: An execution's test run configuration.
> +            execution_result: The execution's result.
> +            test_suites_with_cases: The test suites with test cases to run.
> +        """
> +        sut_node = sut_nodes.get(execution.system_under_test_node.name)
> +        tg_node = tg_nodes.get(execution.traffic_generator_node.name)
> +
> +        try:
> +            if not sut_node:
> +                sut_node = SutNode(execution.system_under_test_node)
> +                sut_nodes[sut_node.name] = sut_node
> +            if not tg_node:
> +                tg_node = TGNode(execution.traffic_generator_node)
> +                tg_nodes[tg_node.name] = tg_node
> +        except Exception as e:
> +            failed_node = execution.system_under_test_node.name
> +            if sut_node:
> +                failed_node = execution.traffic_generator_node.name
> +            self._logger.exception(f"The Creation of node {failed_node} failed.")
> +            execution_result.update_setup(Result.FAIL, e)
> +
> +        else:
> +            self._run_execution(
> +                sut_node, tg_node, execution, execution_result, test_suites_with_cases
> +            )
> +
>      def _run_execution(
>          self,
>          sut_node: SutNode,
>          tg_node: TGNode,
>          execution: ExecutionConfiguration,
> +        execution_result: ExecutionResult,
> +        test_suites_with_cases: Iterable[TestSuiteWithCases],
>      ) -> None:
>          """Run the given execution.
>
> @@ -178,11 +391,11 @@ def _run_execution(
>              sut_node: The execution's SUT node.
>              tg_node: The execution's TG node.
>              execution: An execution's test run configuration.
> +            execution_result: The execution's result.
> +            test_suites_with_cases: The test suites with test cases to run.
>          """
>          self._logger.info(f"Running execution with SUT '{execution.system_under_test_node.name}'.")
> -        execution_result = self._result.add_execution(sut_node.config)
>          execution_result.add_sut_info(sut_node.node_info)
> -
>          try:
>              sut_node.set_up_execution(execution)
>              execution_result.update_setup(Result.PASS)
> @@ -192,7 +405,10 @@ def _run_execution(
>
>          else:
>              for build_target in execution.build_targets:
> -                self._run_build_target(sut_node, tg_node, build_target, execution, execution_result)
> +                build_target_result = execution_result.add_build_target(build_target)
> +                self._run_build_target(
> +                    sut_node, tg_node, build_target, build_target_result, test_suites_with_cases
> +                )
>
>          finally:
>              try:
> @@ -207,8 +423,8 @@ def _run_build_target(
>          sut_node: SutNode,
>          tg_node: TGNode,
>          build_target: BuildTargetConfiguration,
> -        execution: ExecutionConfiguration,
> -        execution_result: ExecutionResult,
> +        build_target_result: BuildTargetResult,
> +        test_suites_with_cases: Iterable[TestSuiteWithCases],
>      ) -> None:
>          """Run the given build target.
>
> @@ -220,11 +436,11 @@ def _run_build_target(
>              sut_node: The execution's sut node.
>              tg_node: The execution's tg node.
>              build_target: A build target's test run configuration.
> -            execution: The build target's execution's test run configuration.
> -            execution_result: The execution level result object associated with the execution.
> +            build_target_result: The build target level result object associated
> +                with the current build target.
> +            test_suites_with_cases: The test suites with test cases to run.
>          """
>          self._logger.info(f"Running build target '{build_target.name}'.")
> -        build_target_result = execution_result.add_build_target(build_target)
>
>          try:
>              sut_node.set_up_build_target(build_target)
> @@ -236,7 +452,7 @@ def _run_build_target(
>              build_target_result.update_setup(Result.FAIL, e)
>
>          else:
> -            self._run_test_suites(sut_node, tg_node, execution, build_target_result)
> +            self._run_test_suites(sut_node, tg_node, build_target_result, test_suites_with_cases)
>
>          finally:
>              try:
> @@ -250,10 +466,10 @@ def _run_test_suites(
>          self,
>          sut_node: SutNode,
>          tg_node: TGNode,
> -        execution: ExecutionConfiguration,
>          build_target_result: BuildTargetResult,
> +        test_suites_with_cases: Iterable[TestSuiteWithCases],
>      ) -> None:
> -        """Run the execution's (possibly a subset of) test suites using the current build target.
> +        """Run `test_suites_with_cases` with the current build target.
>
>          The method assumes the build target we're testing has already been built on the SUT node.
>          The current build target thus corresponds to the current DPDK build present on the SUT node.
> @@ -264,22 +480,20 @@ def _run_test_suites(
>          Args:
>              sut_node: The execution's SUT node.
>              tg_node: The execution's TG node.
> -            execution: The execution's test run configuration associated
> -                with the current build target.
>              build_target_result: The build target level result object associated
>                  with the current build target.
> +            test_suites_with_cases: The test suites with test cases to run.
>          """
>          end_build_target = False
> -        if not execution.skip_smoke_tests:
> -            execution.test_suites[:0] = [TestSuiteConfig.from_dict("smoke_tests")]
> -        for test_suite_config in execution.test_suites:
> +        for test_suite_with_cases in test_suites_with_cases:
> +            test_suite_result = build_target_result.add_test_suite(
> +                test_suite_with_cases.test_suite_class.__name__
> +            )
>              try:
> -                self._run_test_suite_module(
> -                    sut_node, tg_node, execution, build_target_result, test_suite_config
> -                )
> +                self._run_test_suite(sut_node, tg_node, test_suite_result, test_suite_with_cases)
>              except BlockingTestSuiteError as e:
>                  self._logger.exception(
> -                    f"An error occurred within {test_suite_config.test_suite}. "
> +                    f"An error occurred within {test_suite_with_cases.test_suite_class.__name__}. "
>                      "Skipping build target..."
>                  )
>                  self._result.add_error(e)
> @@ -288,15 +502,14 @@ def _run_test_suites(
>              if end_build_target:
>                  break
>
> -    def _run_test_suite_module(
> +    def _run_test_suite(
>          self,
>          sut_node: SutNode,
>          tg_node: TGNode,
> -        execution: ExecutionConfiguration,
> -        build_target_result: BuildTargetResult,
> -        test_suite_config: TestSuiteConfig,
> +        test_suite_result: TestSuiteResult,
> +        test_suite_with_cases: TestSuiteWithCases,
>      ) -> None:
> -        """Set up, execute and tear down all test suites in a single test suite module.
> +        """Set up, execute and tear down `test_suite_with_cases`.
>
>          The method assumes the build target we're testing has already been built on the SUT node.
>          The current build target thus corresponds to the current DPDK build present on the SUT node.
> @@ -306,92 +519,79 @@ def _run_test_suite_module(
>
>          Record the setup and the teardown and handle failures.
>
> -        The test cases to execute are discovered when creating the :class:`TestSuite` object.
> -
>          Args:
>              sut_node: The execution's SUT node.
>              tg_node: The execution's TG node.
> -            execution: The execution's test run configuration associated
> -                with the current build target.
> -            build_target_result: The build target level result object associated
> -                with the current build target.
> -            test_suite_config: Test suite test run configuration specifying the test suite module
> -                and possibly a subset of test cases of test suites in that module.
> +            test_suite_result: The test suite level result object associated
> +                with the current test suite.
> +            test_suite_with_cases: The test suite with test cases to run.
>
>          Raises:
>              BlockingTestSuiteError: If a blocking test suite fails.
>          """
> +        test_suite_name = test_suite_with_cases.test_suite_class.__name__
> +        test_suite = test_suite_with_cases.test_suite_class(sut_node, tg_node)
>          try:
> -            full_suite_path = f"tests.TestSuite_{test_suite_config.test_suite}"
> -            test_suite_classes = get_test_suites(full_suite_path)
> -            suites_str = ", ".join((x.__name__ for x in test_suite_classes))
> -            self._logger.debug(f"Found test suites '{suites_str}' in '{full_suite_path}'.")
> +            self._logger.info(f"Starting test suite setup: {test_suite_name}")
> +            test_suite.set_up_suite()
> +            test_suite_result.update_setup(Result.PASS)
> +            self._logger.info(f"Test suite setup successful: {test_suite_name}")
>          except Exception as e:
> -            self._logger.exception("An error occurred when searching for test suites.")
> -            self._result.update_setup(Result.ERROR, e)
> +            self._logger.exception(f"Test suite setup ERROR: {test_suite_name}")
> +            test_suite_result.update_setup(Result.ERROR, e)
>
>          else:
> -            for test_suite_class in test_suite_classes:
> -                test_suite = test_suite_class(sut_node, tg_node, test_suite_config.test_cases)
> -
> -                test_suite_name = test_suite.__class__.__name__
> -                test_suite_result = build_target_result.add_test_suite(test_suite_name)
> -                try:
> -                    self._logger.info(f"Starting test suite setup: {test_suite_name}")
> -                    test_suite.set_up_suite()
> -                    test_suite_result.update_setup(Result.PASS)
> -                    self._logger.info(f"Test suite setup successful: {test_suite_name}")
> -                except Exception as e:
> -                    self._logger.exception(f"Test suite setup ERROR: {test_suite_name}")
> -                    test_suite_result.update_setup(Result.ERROR, e)
> -
> -                else:
> -                    self._execute_test_suite(execution.func, test_suite, test_suite_result)
> -
> -                finally:
> -                    try:
> -                        test_suite.tear_down_suite()
> -                        sut_node.kill_cleanup_dpdk_apps()
> -                        test_suite_result.update_teardown(Result.PASS)
> -                    except Exception as e:
> -                        self._logger.exception(f"Test suite teardown ERROR: {test_suite_name}")
> -                        self._logger.warning(
> -                            f"Test suite '{test_suite_name}' teardown failed, "
> -                            f"the next test suite may be affected."
> -                        )
> -                        test_suite_result.update_setup(Result.ERROR, e)
> -                    if len(test_suite_result.get_errors()) > 0 and test_suite.is_blocking:
> -                        raise BlockingTestSuiteError(test_suite_name)
> +            self._execute_test_suite(
> +                test_suite,
> +                test_suite_with_cases.test_cases,
> +                test_suite_result,
> +            )
> +        finally:
> +            try:
> +                test_suite.tear_down_suite()
> +                sut_node.kill_cleanup_dpdk_apps()
> +                test_suite_result.update_teardown(Result.PASS)
> +            except Exception as e:
> +                self._logger.exception(f"Test suite teardown ERROR: {test_suite_name}")
> +                self._logger.warning(
> +                    f"Test suite '{test_suite_name}' teardown failed, "
> +                    "the next test suite may be affected."
> +                )
> +                test_suite_result.update_setup(Result.ERROR, e)
> +            if len(test_suite_result.get_errors()) > 0 and test_suite.is_blocking:
> +                raise BlockingTestSuiteError(test_suite_name)
>
>      def _execute_test_suite(
> -        self, func: bool, test_suite: TestSuite, test_suite_result: TestSuiteResult
> +        self,
> +        test_suite: TestSuite,
> +        test_cases: Iterable[MethodType],
> +        test_suite_result: TestSuiteResult,
>      ) -> None:
> -        """Execute all discovered test cases in `test_suite`.
> +        """Execute all `test_cases` in `test_suite`.
>
>          If the :option:`--re-run` command line argument or the :envvar:`DTS_RERUN` environment
>          variable is set, in case of a test case failure, the test case will be executed again
>          until it passes or it fails that many times in addition of the first failure.
>
>          Args:
> -            func: Whether to execute functional test cases.
>              test_suite: The test suite object.
> +            test_cases: The list of test case methods.
>              test_suite_result: The test suite level result object associated
>                  with the current test suite.
>          """
> -        if func:
> -            for test_case_method in test_suite._get_functional_test_cases():
> -                test_case_name = test_case_method.__name__
> -                test_case_result = test_suite_result.add_test_case(test_case_name)
> -                all_attempts = SETTINGS.re_run + 1
> -                attempt_nr = 1
> +        for test_case_method in test_cases:
> +            test_case_name = test_case_method.__name__
> +            test_case_result = test_suite_result.add_test_case(test_case_name)
> +            all_attempts = SETTINGS.re_run + 1
> +            attempt_nr = 1
> +            self._run_test_case(test_suite, test_case_method, test_case_result)
> +            while not test_case_result and attempt_nr < all_attempts:
> +                attempt_nr += 1
> +                self._logger.info(
> +                    f"Re-running FAILED test case '{test_case_name}'. "
> +                    f"Attempt number {attempt_nr} out of {all_attempts}."
> +                )
>                  self._run_test_case(test_suite, test_case_method, test_case_result)
> -                while not test_case_result and attempt_nr < all_attempts:
> -                    attempt_nr += 1
> -                    self._logger.info(
> -                        f"Re-running FAILED test case '{test_case_name}'. "
> -                        f"Attempt number {attempt_nr} out of {all_attempts}."
> -                    )
> -                    self._run_test_case(test_suite, test_case_method, test_case_result)
>
>      def _run_test_case(
>          self,
> @@ -399,7 +599,7 @@ def _run_test_case(
>          test_case_method: MethodType,
>          test_case_result: TestCaseResult,
>      ) -> None:
> -        """Setup, execute and teardown a test case in `test_suite`.
> +        """Setup, execute and teardown `test_case_method` from `test_suite`.
>
>          Record the result of the setup and the teardown and handle failures.
>
> @@ -424,7 +624,7 @@ def _run_test_case(
>
>          else:
>              # run test case if setup was successful
> -            self._execute_test_case(test_case_method, test_case_result)
> +            self._execute_test_case(test_suite, test_case_method, test_case_result)
>
>          finally:
>              try:
> @@ -440,11 +640,15 @@ def _run_test_case(
>                  test_case_result.update(Result.ERROR)
>
>      def _execute_test_case(
> -        self, test_case_method: MethodType, test_case_result: TestCaseResult
> +        self,
> +        test_suite: TestSuite,
> +        test_case_method: MethodType,
> +        test_case_result: TestCaseResult,
>      ) -> None:
> -        """Execute one test case, record the result and handle failures.
> +        """Execute `test_case_method` from `test_suite`, record the result and handle failures.
>
>          Args:
> +            test_suite: The test suite object.
>              test_case_method: The test case method.
>              test_case_result: The test case level result object associated
>                  with the current test case.
> @@ -452,7 +656,7 @@ def _execute_test_case(
>          test_case_name = test_case_method.__name__
>          try:
>              self._logger.info(f"Starting test case execution: {test_case_name}")
> -            test_case_method()
> +            test_case_method(test_suite)
>              test_case_result.update(Result.PASS)
>              self._logger.info(f"Test case execution PASSED: {test_case_name}")
>
> diff --git a/dts/framework/settings.py b/dts/framework/settings.py
> index 609c8d0e62..2b8bfbe0ed 100644
> --- a/dts/framework/settings.py
> +++ b/dts/framework/settings.py
> @@ -253,8 +253,7 @@ def _get_parser() -> argparse.ArgumentParser:
>          "--test-cases",
>          action=_env_arg("DTS_TESTCASES"),
>          default="",
> -        help="[DTS_TESTCASES] Comma-separated list of test cases to execute. "
> -        "Unknown test cases will be silently ignored.",
> +        help="[DTS_TESTCASES] Comma-separated list of test cases to execute.",
>      )
>
>      parser.add_argument(
> diff --git a/dts/framework/test_result.py b/dts/framework/test_result.py
> index 4467749a9d..075195fd5b 100644
> --- a/dts/framework/test_result.py
> +++ b/dts/framework/test_result.py
> @@ -25,7 +25,9 @@
>
>  import os.path
>  from collections.abc import MutableSequence
> +from dataclasses import dataclass
>  from enum import Enum, auto
> +from types import MethodType
>
>  from .config import (
>      OS,
> @@ -36,10 +38,42 @@
>      CPUType,
>      NodeConfiguration,
>      NodeInfo,
> +    TestSuiteConfig,
>  )
>  from .exception import DTSError, ErrorSeverity
>  from .logger import DTSLOG
>  from .settings import SETTINGS
> +from .test_suite import TestSuite
> +
> +
> +@dataclass(slots=True, frozen=True)
> +class TestSuiteWithCases:
> +    """A test suite class with test case methods.
> +
> +    An auxiliary class holding a test case class with test case methods. The intended use of this
> +    class is to hold a subset of test cases (which could be all test cases) because we don't have
> +    all the data to instantiate the class at the point of inspection. The knowledge of this subset
> +    is needed in case an error occurs before the class is instantiated and we need to record
> +    which test cases were blocked by the error.
> +
> +    Attributes:
> +        test_suite_class: The test suite class.
> +        test_cases: The test case methods.
> +    """
> +
> +    test_suite_class: type[TestSuite]
> +    test_cases: list[MethodType]
> +
> +    def create_config(self) -> TestSuiteConfig:
> +        """Generate a :class:`TestSuiteConfig` from the stored test suite with test cases.
> +
> +        Returns:
> +            The :class:`TestSuiteConfig` representation.
> +        """
> +        return TestSuiteConfig(
> +            test_suite=self.test_suite_class.__name__,
> +            test_cases=[test_case.__name__ for test_case in self.test_cases],
> +        )
>
>
>  class Result(Enum):
> diff --git a/dts/framework/test_suite.py b/dts/framework/test_suite.py
> index b02fd36147..f9fe88093e 100644
> --- a/dts/framework/test_suite.py
> +++ b/dts/framework/test_suite.py
> @@ -11,25 +11,17 @@
>      * Testbed (SUT, TG) configuration,
>      * Packet sending and verification,
>      * Test case verification.
> -
> -The module also defines a function, :func:`get_test_suites`,
> -for gathering test suites from a Python module.
>  """
>
> -import importlib
> -import inspect
> -import re
>  from ipaddress import IPv4Interface, IPv6Interface, ip_interface
> -from types import MethodType
> -from typing import Any, ClassVar, Union
> +from typing import ClassVar, Union
>
>  from scapy.layers.inet import IP  # type: ignore[import]
>  from scapy.layers.l2 import Ether  # type: ignore[import]
>  from scapy.packet import Packet, Padding  # type: ignore[import]
>
> -from .exception import ConfigurationError, TestCaseVerifyError
> +from .exception import TestCaseVerifyError
>  from .logger import DTSLOG, getLogger
> -from .settings import SETTINGS
>  from .testbed_model import Port, PortLink, SutNode, TGNode
>  from .utils import get_packet_summaries
>
> @@ -37,7 +29,6 @@
>  class TestSuite(object):
>      """The base class with building blocks needed by most test cases.
>
> -        * Test case filtering and collection,
>          * Test suite setup/cleanup methods to override,
>          * Test case setup/cleanup methods to override,
>          * Test case verification,
> @@ -71,7 +62,6 @@ class TestSuite(object):
>      #: will block the execution of all subsequent test suites in the current build target.
>      is_blocking: ClassVar[bool] = False
>      _logger: DTSLOG
> -    _test_cases_to_run: list[str]
>      _port_links: list[PortLink]
>      _sut_port_ingress: Port
>      _sut_port_egress: Port
> @@ -86,24 +76,19 @@ def __init__(
>          self,
>          sut_node: SutNode,
>          tg_node: TGNode,
> -        test_cases: list[str],
>      ):
>          """Initialize the test suite testbed information and basic configuration.
>
> -        Process what test cases to run, find links between ports and set up
> -        default IP addresses to be used when configuring them.
> +        Find links between ports and set up default IP addresses to be used when
> +        configuring them.
>
>          Args:
>              sut_node: The SUT node where the test suite will run.
>              tg_node: The TG node where the test suite will run.
> -            test_cases: The list of test cases to execute.
> -                If empty, all test cases will be executed.
>          """
>          self.sut_node = sut_node
>          self.tg_node = tg_node
>          self._logger = getLogger(self.__class__.__name__)
> -        self._test_cases_to_run = test_cases
> -        self._test_cases_to_run.extend(SETTINGS.test_cases)
>          self._port_links = []
>          self._process_links()
>          self._sut_port_ingress, self._tg_port_egress = (
> @@ -364,65 +349,3 @@ def _verify_l3_packet(self, received_packet: IP, expected_packet: IP) -> bool:
>          if received_packet.src != expected_packet.src or received_packet.dst != expected_packet.dst:
>              return False
>          return True
> -
> -    def _get_functional_test_cases(self) -> list[MethodType]:
> -        """Get all functional test cases defined in this TestSuite.
> -
> -        Returns:
> -            The list of functional test cases of this TestSuite.
> -        """
> -        return self._get_test_cases(r"test_(?!perf_)")
> -
> -    def _get_test_cases(self, test_case_regex: str) -> list[MethodType]:
> -        """Return a list of test cases matching test_case_regex.
> -
> -        Returns:
> -            The list of test cases matching test_case_regex of this TestSuite.
> -        """
> -        self._logger.debug(f"Searching for test cases in {self.__class__.__name__}.")
> -        filtered_test_cases = []
> -        for test_case_name, test_case in inspect.getmembers(self, inspect.ismethod):
> -            if self._should_be_executed(test_case_name, test_case_regex):
> -                filtered_test_cases.append(test_case)
> -        cases_str = ", ".join((x.__name__ for x in filtered_test_cases))
> -        self._logger.debug(f"Found test cases '{cases_str}' in {self.__class__.__name__}.")
> -        return filtered_test_cases
> -
> -    def _should_be_executed(self, test_case_name: str, test_case_regex: str) -> bool:
> -        """Check whether the test case should be scheduled to be executed."""
> -        match = bool(re.match(test_case_regex, test_case_name))
> -        if self._test_cases_to_run:
> -            return match and test_case_name in self._test_cases_to_run
> -
> -        return match
> -
> -
> -def get_test_suites(testsuite_module_path: str) -> list[type[TestSuite]]:
> -    r"""Find all :class:`TestSuite`\s in a Python module.
> -
> -    Args:
> -        testsuite_module_path: The path to the Python module.
> -
> -    Returns:
> -        The list of :class:`TestSuite`\s found within the Python module.
> -
> -    Raises:
> -        ConfigurationError: The test suite module was not found.
> -    """
> -
> -    def is_test_suite(object: Any) -> bool:
> -        try:
> -            if issubclass(object, TestSuite) and object is not TestSuite:
> -                return True
> -        except TypeError:
> -            return False
> -        return False
> -
> -    try:
> -        testcase_module = importlib.import_module(testsuite_module_path)
> -    except ModuleNotFoundError as e:
> -        raise ConfigurationError(f"Test suite '{testsuite_module_path}' not found.") from e
> -    return [
> -        test_suite_class
> -        for _, test_suite_class in inspect.getmembers(testcase_module, is_test_suite)
> -    ]
> diff --git a/dts/pyproject.toml b/dts/pyproject.toml
> index 28bd970ae4..8eb92b4f11 100644
> --- a/dts/pyproject.toml
> +++ b/dts/pyproject.toml
> @@ -51,6 +51,9 @@ linters = "mccabe,pycodestyle,pydocstyle,pyflakes"
>  format = "pylint"
>  max_line_length = 100
>
> +[tool.pylama.linter.pycodestyle]
> +ignore = "E203,W503"
> +
>  [tool.pylama.linter.pydocstyle]
>  convention = "google"
>
> diff --git a/dts/tests/TestSuite_smoke_tests.py b/dts/tests/TestSuite_smoke_tests.py
> index 5e2bac14bd..7b2a0e97f8 100644
> --- a/dts/tests/TestSuite_smoke_tests.py
> +++ b/dts/tests/TestSuite_smoke_tests.py
> @@ -21,7 +21,7 @@
>  from framework.utils import REGEX_FOR_PCI_ADDRESS
>
>
> -class SmokeTests(TestSuite):
> +class TestSmokeTests(TestSuite):
>      """DPDK and infrastructure smoke test suite.
>
>      The test cases validate the most basic DPDK functionality needed for all other test suites.
> --
> 2.34.1
>

^ permalink raw reply	[flat|nested] 28+ messages in thread

* Re: [PATCH v2 6/7] dts: refactor logging configuration
  2024-02-06 14:57   ` [PATCH v2 6/7] dts: refactor logging configuration Juraj Linkeš
@ 2024-02-12 16:45     ` Jeremy Spewock
  2024-02-14  7:49       ` Juraj Linkeš
  0 siblings, 1 reply; 28+ messages in thread
From: Jeremy Spewock @ 2024-02-12 16:45 UTC (permalink / raw)
  To: Juraj Linkeš
  Cc: thomas, Honnappa.Nagarahalli, probb, paul.szczepanek, Luca.Vizzarro, dev

On Tue, Feb 6, 2024 at 9:57 AM Juraj Linkeš <juraj.linkes@pantheon.tech> wrote:
>
> Remove unused parts of the code and add useful features:
> 1. Add DTS execution stages such as execution and test suite to better
>    identify where in the DTS lifecycle we are when investigating logs,
> 2. Logging to separate files in specific stages, which is mainly useful
>    for having test suite logs in additional separate files.
> 3. Remove the dependence on the settings module which enhances the
>    usefulness of the logger module, as it can now be imported in more
>    modules.
>
> The execution stages and the files to log to are the same for all DTS
> loggers. To achieve this, we have one DTS root logger which should be
> used for handling stage switching and all other loggers are children of
> this DTS root logger. The DTS root logger is the one where we change the
> behavior of all loggers (the stage and which files to log to) and the
> child loggers just log messages under a different name.
>
> Signed-off-by: Juraj Linkeš <juraj.linkes@pantheon.tech>
> ---
>  dts/framework/logger.py                       | 235 +++++++++++-------
>  dts/framework/remote_session/__init__.py      |   6 +-
>  .../interactive_remote_session.py             |   6 +-
>  .../remote_session/interactive_shell.py       |   6 +-
>  .../remote_session/remote_session.py          |   8 +-
>  dts/framework/runner.py                       |  19 +-
>  dts/framework/test_result.py                  |   6 +-
>  dts/framework/test_suite.py                   |   6 +-
>  dts/framework/testbed_model/node.py           |  11 +-
>  dts/framework/testbed_model/os_session.py     |   7 +-
>  .../traffic_generator/traffic_generator.py    |   6 +-
>  dts/main.py                                   |   1 -
>  12 files changed, 183 insertions(+), 134 deletions(-)
>
> diff --git a/dts/framework/logger.py b/dts/framework/logger.py
> index cfa6e8cd72..568edad82d 100644
> --- a/dts/framework/logger.py
> +++ b/dts/framework/logger.py
> @@ -5,141 +5,186 @@
>
>  """DTS logger module.
>
> -DTS framework and TestSuite logs are saved in different log files.
> +The module provides several additional features:
> +
> +    * The storage of DTS execution stages,
> +    * Logging to console, a human-readable log file and a machine-readable log file,
> +    * Optional log files for specific stages.
>  """
>
>  import logging
> -import os.path
> -from typing import TypedDict
> +from enum import auto
> +from logging import FileHandler, StreamHandler
> +from pathlib import Path
> +from typing import ClassVar
>
> -from .settings import SETTINGS
> +from .utils import StrEnum
>
>  date_fmt = "%Y/%m/%d %H:%M:%S"
> -stream_fmt = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
> +stream_fmt = "%(asctime)s - %(stage)s - %(name)s - %(levelname)s - %(message)s"
> +dts_root_logger_name = "dts"
> +
> +
> +class DtsStage(StrEnum):
> +    """The DTS execution stage."""
>
> +    #:
> +    pre_execution = auto()
> +    #:
> +    execution = auto()
> +    #:
> +    build_target = auto()
> +    #:
> +    suite = auto()
> +    #:
> +    post_execution = auto()
>
> -class DTSLOG(logging.LoggerAdapter):
> -    """DTS logger adapter class for framework and testsuites.
>
> -    The :option:`--verbose` command line argument and the :envvar:`DTS_VERBOSE` environment
> -    variable control the verbosity of output. If enabled, all messages will be emitted to the
> -    console.
> +class DTSLogger(logging.Logger):
> +    """The DTS logger class.
>
> -    The :option:`--output` command line argument and the :envvar:`DTS_OUTPUT_DIR` environment
> -    variable modify the directory where the logs will be stored.
> +    The class extends the :class:`~logging.Logger` class to add the DTS execution stage information
> +    to log records. The stage is common to all loggers, so it's stored in a class variable.
>
> -    Attributes:
> -        node: The additional identifier. Currently unused.
> -        sh: The handler which emits logs to console.
> -        fh: The handler which emits logs to a file.
> -        verbose_fh: Just as fh, but logs with a different, more verbose, format.
> +    Any time we switch to a new stage, we have the ability to log to an additional log file along
> +    with a supplementary log file with machine-readable format. These two log files are used until
> +    a new stage switch occurs. This is useful mainly for logging per test suite.
>      """
>
> -    _logger: logging.Logger
> -    node: str
> -    sh: logging.StreamHandler
> -    fh: logging.FileHandler
> -    verbose_fh: logging.FileHandler
> +    _stage: ClassVar[DtsStage] = DtsStage.pre_execution
> +    _extra_file_handlers: list[FileHandler] = []
>
> -    def __init__(self, logger: logging.Logger, node: str = "suite"):
> -        """Extend the constructor with additional handlers.
> +    def __init__(self, *args, **kwargs):
> +        """Extend the constructor with extra file handlers."""
> +        self._extra_file_handlers = []
> +        super().__init__(*args, **kwargs)
>
> -        One handler logs to the console, the other one to a file, with either a regular or verbose
> -        format.
> +    def makeRecord(self, *args, **kwargs):

Is the return type annotation here skipped because of the `:meta private:`?

> +        """Generates a record with additional stage information.
>
> -        Args:
> -            logger: The logger from which to create the logger adapter.
> -            node: An additional identifier. Currently unused.
> +        This is the default method for the :class:`~logging.Logger` class. We extend it
> +        to add stage information to the record.
> +
> +        :meta private:
> +
> +        Returns:
> +            record: The generated record with the stage information.
>          """
> -        self._logger = logger
> -        # 1 means log everything, this will be used by file handlers if their level
> -        # is not set
> -        self._logger.setLevel(1)
> +        record = super().makeRecord(*args, **kwargs)
> +        record.stage = DTSLogger._stage
> +        return record
> +
> +    def add_dts_root_logger_handlers(self, verbose: bool, output_dir: str) -> None:
> +        """Add logger handlers to the DTS root logger.
> +
> +        This method should be called only on the DTS root logger.
> +        The log records from child loggers will propagate to these handlers.
> +
> +        Three handlers are added:
>
> -        self.node = node
> +            * A console handler,
> +            * A file handler,
> +            * A supplementary file handler with machine-readable logs
> +              containing more debug information.
>
> -        # add handler to emit to stdout
> -        sh = logging.StreamHandler()
> +        All log messages will be logged to files. The log level of the console handler
> +        is configurable with `verbose`.
> +
> +        Args:
> +            verbose: If :data:`True`, log all messages to the console.
> +                If :data:`False`, log to console with the :data:`logging.INFO` level.
> +            output_dir: The directory where the log files will be located.
> +                The names of the log files correspond to the name of the logger instance.
> +        """
> +        self.setLevel(1)
> +
> +        sh = StreamHandler()
>          sh.setFormatter(logging.Formatter(stream_fmt, date_fmt))
> -        sh.setLevel(logging.INFO)  # console handler default level
> +        if not verbose:
> +            sh.setLevel(logging.INFO)
> +        self.addHandler(sh)
>
> -        if SETTINGS.verbose is True:
> -            sh.setLevel(logging.DEBUG)
> +        self._add_file_handlers(Path(output_dir, self.name))
>
> -        self._logger.addHandler(sh)
> -        self.sh = sh
> +    def set_stage(self, stage: DtsStage, log_file_path: Path | None = None) -> None:
> +        """Set the DTS execution stage and optionally log to files.
>
> -        # prepare the output folder
> -        if not os.path.exists(SETTINGS.output_dir):
> -            os.mkdir(SETTINGS.output_dir)
> +        Set the DTS execution stage of the DTSLog class and optionally add
> +        file handlers to the instance if the log file name is provided.
>
> -        logging_path_prefix = os.path.join(SETTINGS.output_dir, node)
> +        The file handlers log all messages. One is a regular human-readable log file and
> +        the other one is a machine-readable log file with extra debug information.
>
> -        fh = logging.FileHandler(f"{logging_path_prefix}.log")
> -        fh.setFormatter(
> -            logging.Formatter(
> -                fmt="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
> -                datefmt=date_fmt,
> -            )
> -        )
> +        Args:
> +            stage: The DTS stage to set.
> +            log_file_path: An optional path of the log file to use. This should be a full path
> +                (either relative or absolute) without suffix (which will be appended).
> +        """
> +        self._remove_extra_file_handlers()
> +
> +        if DTSLogger._stage != stage:
> +            self.info(f"Moving from stage '{DTSLogger._stage}' to stage '{stage}'.")
> +            DTSLogger._stage = stage
> +
> +        if log_file_path:
> +            self._extra_file_handlers.extend(self._add_file_handlers(log_file_path))
> +
> +    def _add_file_handlers(self, log_file_path: Path) -> list[FileHandler]:
> +        """Add file handlers to the DTS root logger.
> +
> +        Add two type of file handlers:
> +
> +            * A regular file handler with suffix ".log",
> +            * A machine-readable file handler with suffix ".verbose.log".
> +              This format provides extensive information for debugging and detailed analysis.
> +
> +        Args:
> +            log_file_path: The full path to the log file without suffix.
> +
> +        Returns:
> +            The newly created file handlers.
>
> -        self._logger.addHandler(fh)
> -        self.fh = fh
> +        """
> +        fh = FileHandler(f"{log_file_path}.log")
> +        fh.setFormatter(logging.Formatter(stream_fmt, date_fmt))
> +        self.addHandler(fh)
>
> -        # This outputs EVERYTHING, intended for post-mortem debugging
> -        # Also optimized for processing via AWK (awk -F '|' ...)
> -        verbose_fh = logging.FileHandler(f"{logging_path_prefix}.verbose.log")
> +        verbose_fh = FileHandler(f"{log_file_path}.verbose.log")
>          verbose_fh.setFormatter(
>              logging.Formatter(
> -                fmt="%(asctime)s|%(name)s|%(levelname)s|%(pathname)s|%(lineno)d|"
> +                "%(asctime)s|%(stage)s|%(name)s|%(levelname)s|%(pathname)s|%(lineno)d|"
>                  "%(funcName)s|%(process)d|%(thread)d|%(threadName)s|%(message)s",
>                  datefmt=date_fmt,
>              )
>          )
> +        self.addHandler(verbose_fh)
>
> -        self._logger.addHandler(verbose_fh)
> -        self.verbose_fh = verbose_fh
> -
> -        super(DTSLOG, self).__init__(self._logger, dict(node=self.node))
> -
> -    def logger_exit(self) -> None:
> -        """Remove the stream handler and the logfile handler."""
> -        for handler in (self.sh, self.fh, self.verbose_fh):
> -            handler.flush()
> -            self._logger.removeHandler(handler)
> -
> -
> -class _LoggerDictType(TypedDict):
> -    logger: DTSLOG
> -    name: str
> -    node: str
> -
> +        return [fh, verbose_fh]
>
> -# List for saving all loggers in use
> -_Loggers: list[_LoggerDictType] = []
> +    def _remove_extra_file_handlers(self) -> None:
> +        """Remove any extra file handlers that have been added to the logger."""
> +        if self._extra_file_handlers:
> +            for extra_file_handler in self._extra_file_handlers:
> +                self.removeHandler(extra_file_handler)
>
> +            self._extra_file_handlers = []
>
> -def getLogger(name: str, node: str = "suite") -> DTSLOG:
> -    """Get DTS logger adapter identified by name and node.
>
> -    An existing logger will be returned if one with the exact name and node already exists.
> -    A new one will be created and stored otherwise.
> +def get_dts_logger(name: str = None) -> DTSLogger:
> +    """Return a DTS logger instance identified by `name`.
>
>      Args:
> -        name: The name of the logger.
> -        node: An additional identifier for the logger.
> +        name: If :data:`None`, return the DTS root logger.
> +            If specified, return a child of the DTS root logger.
>
>      Returns:
> -        A logger uniquely identified by both name and node.
> +         The DTS root logger or a child logger identified by `name`.
>      """
> -    global _Loggers
> -    # return saved logger
> -    logger: _LoggerDictType
> -    for logger in _Loggers:
> -        if logger["name"] == name and logger["node"] == node:
> -            return logger["logger"]
> -
> -    # return new logger
> -    dts_logger: DTSLOG = DTSLOG(logging.getLogger(name), node)
> -    _Loggers.append({"logger": dts_logger, "name": name, "node": node})
> -    return dts_logger
> +    logging.setLoggerClass(DTSLogger)
> +    if name:
> +        name = f"{dts_root_logger_name}.{name}"
> +    else:
> +        name = dts_root_logger_name
> +    logger = logging.getLogger(name)
> +    logging.setLoggerClass(logging.Logger)

What's the benefit of setting the logger class back to logging.Logger
here? Is the idea basically that if someone wanted to use the logging
module we shouldn't implicitly make them use our DTSLogger?

> +    return logger  # type: ignore[return-value]
> diff --git a/dts/framework/remote_session/__init__.py b/dts/framework/remote_session/__init__.py
> index 51a01d6b5e..1910c81c3c 100644
> --- a/dts/framework/remote_session/__init__.py
> +++ b/dts/framework/remote_session/__init__.py
> @@ -15,7 +15,7 @@
>  # pylama:ignore=W0611
>
>  from framework.config import NodeConfiguration
> -from framework.logger import DTSLOG
> +from framework.logger import DTSLogger
>
>  from .interactive_remote_session import InteractiveRemoteSession
>  from .interactive_shell import InteractiveShell
> @@ -26,7 +26,7 @@
>
>
>  def create_remote_session(
> -    node_config: NodeConfiguration, name: str, logger: DTSLOG
> +    node_config: NodeConfiguration, name: str, logger: DTSLogger
>  ) -> RemoteSession:
>      """Factory for non-interactive remote sessions.
>
> @@ -45,7 +45,7 @@ def create_remote_session(
>
>
>  def create_interactive_session(
> -    node_config: NodeConfiguration, logger: DTSLOG
> +    node_config: NodeConfiguration, logger: DTSLogger
>  ) -> InteractiveRemoteSession:
>      """Factory for interactive remote sessions.
>
> diff --git a/dts/framework/remote_session/interactive_remote_session.py b/dts/framework/remote_session/interactive_remote_session.py
> index 1cc82e3377..c50790db79 100644
> --- a/dts/framework/remote_session/interactive_remote_session.py
> +++ b/dts/framework/remote_session/interactive_remote_session.py
> @@ -16,7 +16,7 @@
>
>  from framework.config import NodeConfiguration
>  from framework.exception import SSHConnectionError
> -from framework.logger import DTSLOG
> +from framework.logger import DTSLogger
>
>
>  class InteractiveRemoteSession:
> @@ -50,11 +50,11 @@ class InteractiveRemoteSession:
>      username: str
>      password: str
>      session: SSHClient
> -    _logger: DTSLOG
> +    _logger: DTSLogger
>      _node_config: NodeConfiguration
>      _transport: Transport | None
>
> -    def __init__(self, node_config: NodeConfiguration, logger: DTSLOG) -> None:
> +    def __init__(self, node_config: NodeConfiguration, logger: DTSLogger) -> None:
>          """Connect to the node during initialization.
>
>          Args:
> diff --git a/dts/framework/remote_session/interactive_shell.py b/dts/framework/remote_session/interactive_shell.py
> index b158f963b6..5cfe202e15 100644
> --- a/dts/framework/remote_session/interactive_shell.py
> +++ b/dts/framework/remote_session/interactive_shell.py
> @@ -20,7 +20,7 @@
>
>  from paramiko import Channel, SSHClient, channel  # type: ignore[import]
>
> -from framework.logger import DTSLOG
> +from framework.logger import DTSLogger
>  from framework.settings import SETTINGS
>
>
> @@ -38,7 +38,7 @@ class InteractiveShell(ABC):
>      _stdin: channel.ChannelStdinFile
>      _stdout: channel.ChannelFile
>      _ssh_channel: Channel
> -    _logger: DTSLOG
> +    _logger: DTSLogger
>      _timeout: float
>      _app_args: str
>
> @@ -61,7 +61,7 @@ class InteractiveShell(ABC):
>      def __init__(
>          self,
>          interactive_session: SSHClient,
> -        logger: DTSLOG,
> +        logger: DTSLogger,
>          get_privileged_command: Callable[[str], str] | None,
>          app_args: str = "",
>          timeout: float = SETTINGS.timeout,
> diff --git a/dts/framework/remote_session/remote_session.py b/dts/framework/remote_session/remote_session.py
> index 2059f9a981..a69dc99400 100644
> --- a/dts/framework/remote_session/remote_session.py
> +++ b/dts/framework/remote_session/remote_session.py
> @@ -9,14 +9,13 @@
>  the structure of the result of a command execution.
>  """
>
> -
>  import dataclasses
>  from abc import ABC, abstractmethod
>  from pathlib import PurePath
>
>  from framework.config import NodeConfiguration
>  from framework.exception import RemoteCommandExecutionError
> -from framework.logger import DTSLOG
> +from framework.logger import DTSLogger
>  from framework.settings import SETTINGS
>
>
> @@ -75,14 +74,14 @@ class RemoteSession(ABC):
>      username: str
>      password: str
>      history: list[CommandResult]
> -    _logger: DTSLOG
> +    _logger: DTSLogger
>      _node_config: NodeConfiguration
>
>      def __init__(
>          self,
>          node_config: NodeConfiguration,
>          session_name: str,
> -        logger: DTSLOG,
> +        logger: DTSLogger,
>      ):
>          """Connect to the node during initialization.
>
> @@ -181,7 +180,6 @@ def close(self, force: bool = False) -> None:
>          Args:
>              force: Force the closure of the connection. This may not clean up all resources.
>          """
> -        self._logger.logger_exit()
>          self._close(force)
>
>      @abstractmethod
> diff --git a/dts/framework/runner.py b/dts/framework/runner.py
> index f58b0adc13..035e3368ef 100644
> --- a/dts/framework/runner.py
> +++ b/dts/framework/runner.py
> @@ -19,9 +19,10 @@
>
>  import importlib
>  import inspect
> -import logging
> +import os
>  import re
>  import sys
> +from pathlib import Path
>  from types import MethodType
>  from typing import Iterable
>
> @@ -38,7 +39,7 @@
>      SSHTimeoutError,
>      TestCaseVerifyError,
>  )
> -from .logger import DTSLOG, getLogger
> +from .logger import DTSLogger, DtsStage, get_dts_logger
>  from .settings import SETTINGS
>  from .test_result import (
>      BuildTargetResult,
> @@ -73,7 +74,7 @@ class DTSRunner:
>      """
>
>      _configuration: Configuration
> -    _logger: DTSLOG
> +    _logger: DTSLogger
>      _result: DTSResult
>      _test_suite_class_prefix: str
>      _test_suite_module_prefix: str
> @@ -83,7 +84,10 @@ class DTSRunner:
>      def __init__(self):
>          """Initialize the instance with configuration, logger, result and string constants."""
>          self._configuration = load_config()
> -        self._logger = getLogger("DTSRunner")
> +        self._logger = get_dts_logger()
> +        if not os.path.exists(SETTINGS.output_dir):
> +            os.makedirs(SETTINGS.output_dir)
> +        self._logger.add_dts_root_logger_handlers(SETTINGS.verbose, SETTINGS.output_dir)
>          self._result = DTSResult(self._logger)
>          self._test_suite_class_prefix = "Test"
>          self._test_suite_module_prefix = "tests.TestSuite_"
> @@ -137,6 +141,7 @@ def run(self):
>
>              # for all Execution sections
>              for execution in self._configuration.executions:
> +                self._logger.set_stage(DtsStage.execution)

This ends up getting set twice in short succession which of course
doesn't functionally cause a problem, but I don't exactly see the
point of setting it twice. We could either set it here or set it in
the _run_execution, but i think it makes more sense to set it in the
_run_execution method as that is the method where we are doing the
setup, here we are only initializing the nodes which is still in a
sense "pre execution."


>                  self._logger.info(
>                      f"Running execution with SUT '{execution.system_under_test_node.name}'."
>                  )
> @@ -164,6 +169,7 @@ def run(self):
>
>          finally:
>              try:
> +                self._logger.set_stage(DtsStage.post_execution)
>                  for node in (sut_nodes | tg_nodes).values():
>                      node.close()
>                  self._result.update_teardown(Result.PASS)
> @@ -419,6 +425,7 @@ def _run_execution(
>
>          finally:
>              try:
> +                self._logger.set_stage(DtsStage.execution)
>                  sut_node.tear_down_execution()
>                  execution_result.update_teardown(Result.PASS)
>              except Exception as e:
> @@ -447,6 +454,7 @@ def _run_build_target(
>                  with the current build target.
>              test_suites_with_cases: The test suites with test cases to run.
>          """
> +        self._logger.set_stage(DtsStage.build_target)
>          self._logger.info(f"Running build target '{build_target.name}'.")
>
>          try:
> @@ -463,6 +471,7 @@ def _run_build_target(
>
>          finally:
>              try:
> +                self._logger.set_stage(DtsStage.build_target)
>                  sut_node.tear_down_build_target()
>                  build_target_result.update_teardown(Result.PASS)
>              except Exception as e:
> @@ -535,6 +544,7 @@ def _run_test_suite(
>              BlockingTestSuiteError: If a blocking test suite fails.
>          """
>          test_suite_name = test_suite_with_cases.test_suite_class.__name__
> +        self._logger.set_stage(DtsStage.suite, Path(SETTINGS.output_dir, test_suite_name))
>          test_suite = test_suite_with_cases.test_suite_class(sut_node, tg_node)
>          try:
>              self._logger.info(f"Starting test suite setup: {test_suite_name}")
> @@ -683,5 +693,4 @@ def _exit_dts(self) -> None:
>          if self._logger:
>              self._logger.info("DTS execution has ended.")
>
> -        logging.shutdown()
>          sys.exit(self._result.get_return_code())
> diff --git a/dts/framework/test_result.py b/dts/framework/test_result.py
> index eedb2d20ee..28f84fd793 100644
> --- a/dts/framework/test_result.py
> +++ b/dts/framework/test_result.py
> @@ -42,7 +42,7 @@
>      TestSuiteConfig,
>  )
>  from .exception import DTSError, ErrorSeverity
> -from .logger import DTSLOG
> +from .logger import DTSLogger
>  from .settings import SETTINGS
>  from .test_suite import TestSuite
>
> @@ -237,13 +237,13 @@ class DTSResult(BaseResult):
>      """
>
>      dpdk_version: str | None
> -    _logger: DTSLOG
> +    _logger: DTSLogger
>      _errors: list[Exception]
>      _return_code: ErrorSeverity
>      _stats_result: Union["Statistics", None]
>      _stats_filename: str
>
> -    def __init__(self, logger: DTSLOG):
> +    def __init__(self, logger: DTSLogger):
>          """Extend the constructor with top-level specifics.
>
>          Args:
> diff --git a/dts/framework/test_suite.py b/dts/framework/test_suite.py
> index f9fe88093e..365f80e21a 100644
> --- a/dts/framework/test_suite.py
> +++ b/dts/framework/test_suite.py
> @@ -21,7 +21,7 @@
>  from scapy.packet import Packet, Padding  # type: ignore[import]
>
>  from .exception import TestCaseVerifyError
> -from .logger import DTSLOG, getLogger
> +from .logger import DTSLogger, get_dts_logger
>  from .testbed_model import Port, PortLink, SutNode, TGNode
>  from .utils import get_packet_summaries
>
> @@ -61,7 +61,7 @@ class TestSuite(object):
>      #: Whether the test suite is blocking. A failure of a blocking test suite
>      #: will block the execution of all subsequent test suites in the current build target.
>      is_blocking: ClassVar[bool] = False
> -    _logger: DTSLOG
> +    _logger: DTSLogger
>      _port_links: list[PortLink]
>      _sut_port_ingress: Port
>      _sut_port_egress: Port
> @@ -88,7 +88,7 @@ def __init__(
>          """
>          self.sut_node = sut_node
>          self.tg_node = tg_node
> -        self._logger = getLogger(self.__class__.__name__)
> +        self._logger = get_dts_logger(self.__class__.__name__)
>          self._port_links = []
>          self._process_links()
>          self._sut_port_ingress, self._tg_port_egress = (
> diff --git a/dts/framework/testbed_model/node.py b/dts/framework/testbed_model/node.py
> index 1a55fadf78..74061f6262 100644
> --- a/dts/framework/testbed_model/node.py
> +++ b/dts/framework/testbed_model/node.py
> @@ -23,7 +23,7 @@
>      NodeConfiguration,
>  )
>  from framework.exception import ConfigurationError
> -from framework.logger import DTSLOG, getLogger
> +from framework.logger import DTSLogger, get_dts_logger
>  from framework.settings import SETTINGS
>
>  from .cpu import (
> @@ -63,7 +63,7 @@ class Node(ABC):
>      name: str
>      lcores: list[LogicalCore]
>      ports: list[Port]
> -    _logger: DTSLOG
> +    _logger: DTSLogger
>      _other_sessions: list[OSSession]
>      _execution_config: ExecutionConfiguration
>      virtual_devices: list[VirtualDevice]
> @@ -82,7 +82,7 @@ def __init__(self, node_config: NodeConfiguration):
>          """
>          self.config = node_config
>          self.name = node_config.name
> -        self._logger = getLogger(self.name)
> +        self._logger = get_dts_logger(self.name)
>          self.main_session = create_session(self.config, self.name, self._logger)
>
>          self._logger.info(f"Connected to node: {self.name}")
> @@ -189,7 +189,7 @@ def create_session(self, name: str) -> OSSession:
>          connection = create_session(
>              self.config,
>              session_name,
> -            getLogger(session_name, node=self.name),
> +            get_dts_logger(session_name),
>          )
>          self._other_sessions.append(connection)
>          return connection
> @@ -299,7 +299,6 @@ def close(self) -> None:
>              self.main_session.close()
>          for session in self._other_sessions:
>              session.close()
> -        self._logger.logger_exit()
>
>      @staticmethod
>      def skip_setup(func: Callable[..., Any]) -> Callable[..., Any]:
> @@ -314,7 +313,7 @@ def skip_setup(func: Callable[..., Any]) -> Callable[..., Any]:
>              return func
>
>
> -def create_session(node_config: NodeConfiguration, name: str, logger: DTSLOG) -> OSSession:
> +def create_session(node_config: NodeConfiguration, name: str, logger: DTSLogger) -> OSSession:
>      """Factory for OS-aware sessions.
>
>      Args:
> diff --git a/dts/framework/testbed_model/os_session.py b/dts/framework/testbed_model/os_session.py
> index ac6bb5e112..6983aa4a77 100644
> --- a/dts/framework/testbed_model/os_session.py
> +++ b/dts/framework/testbed_model/os_session.py
> @@ -21,7 +21,6 @@
>      the :attr:`~.node.Node.main_session` translates that to ``rm -rf`` if the node's OS is Linux
>      and other commands for other OSs. It also translates the path to match the underlying OS.
>  """
> -
>  from abc import ABC, abstractmethod
>  from collections.abc import Iterable
>  from ipaddress import IPv4Interface, IPv6Interface
> @@ -29,7 +28,7 @@
>  from typing import Type, TypeVar, Union
>
>  from framework.config import Architecture, NodeConfiguration, NodeInfo
> -from framework.logger import DTSLOG
> +from framework.logger import DTSLogger
>  from framework.remote_session import (
>      CommandResult,
>      InteractiveRemoteSession,
> @@ -62,7 +61,7 @@ class OSSession(ABC):
>
>      _config: NodeConfiguration
>      name: str
> -    _logger: DTSLOG
> +    _logger: DTSLogger
>      remote_session: RemoteSession
>      interactive_session: InteractiveRemoteSession
>
> @@ -70,7 +69,7 @@ def __init__(
>          self,
>          node_config: NodeConfiguration,
>          name: str,
> -        logger: DTSLOG,
> +        logger: DTSLogger,
>      ):
>          """Initialize the OS-aware session.
>
> diff --git a/dts/framework/testbed_model/traffic_generator/traffic_generator.py b/dts/framework/testbed_model/traffic_generator/traffic_generator.py
> index c49fbff488..d86d7fb532 100644
> --- a/dts/framework/testbed_model/traffic_generator/traffic_generator.py
> +++ b/dts/framework/testbed_model/traffic_generator/traffic_generator.py
> @@ -13,7 +13,7 @@
>  from scapy.packet import Packet  # type: ignore[import]
>
>  from framework.config import TrafficGeneratorConfig
> -from framework.logger import DTSLOG, getLogger
> +from framework.logger import DTSLogger, get_dts_logger
>  from framework.testbed_model.node import Node
>  from framework.testbed_model.port import Port
>  from framework.utils import get_packet_summaries
> @@ -28,7 +28,7 @@ class TrafficGenerator(ABC):
>
>      _config: TrafficGeneratorConfig
>      _tg_node: Node
> -    _logger: DTSLOG
> +    _logger: DTSLogger
>
>      def __init__(self, tg_node: Node, config: TrafficGeneratorConfig):
>          """Initialize the traffic generator.
> @@ -39,7 +39,7 @@ def __init__(self, tg_node: Node, config: TrafficGeneratorConfig):
>          """
>          self._config = config
>          self._tg_node = tg_node
> -        self._logger = getLogger(f"{self._tg_node.name} {self._config.traffic_generator_type}")
> +        self._logger = get_dts_logger(f"{self._tg_node.name} {self._config.traffic_generator_type}")
>
>      def send_packet(self, packet: Packet, port: Port) -> None:
>          """Send `packet` and block until it is fully sent.
> diff --git a/dts/main.py b/dts/main.py
> index 1ffe8ff81f..d30c164b95 100755
> --- a/dts/main.py
> +++ b/dts/main.py
> @@ -30,5 +30,4 @@ def main() -> None:
>
>  # Main program begins here
>  if __name__ == "__main__":
> -    logging.raiseExceptions = True
>      main()
> --
> 2.34.1
>

^ permalink raw reply	[flat|nested] 28+ messages in thread

* Re: [PATCH v2 6/7] dts: refactor logging configuration
  2024-02-12 16:45     ` Jeremy Spewock
@ 2024-02-14  7:49       ` Juraj Linkeš
  2024-02-14 16:51         ` Jeremy Spewock
  0 siblings, 1 reply; 28+ messages in thread
From: Juraj Linkeš @ 2024-02-14  7:49 UTC (permalink / raw)
  To: Jeremy Spewock
  Cc: thomas, Honnappa.Nagarahalli, probb, paul.szczepanek, Luca.Vizzarro, dev

Hi Jeremy,

Just a reminder, please strip the parts you're not commenting on.

On Mon, Feb 12, 2024 at 5:45 PM Jeremy Spewock <jspewock@iol.unh.edu> wrote:
>
> On Tue, Feb 6, 2024 at 9:57 AM Juraj Linkeš <juraj.linkes@pantheon.tech> wrote:
> >
> > Remove unused parts of the code and add useful features:
> > 1. Add DTS execution stages such as execution and test suite to better
> >    identify where in the DTS lifecycle we are when investigating logs,
> > 2. Logging to separate files in specific stages, which is mainly useful
> >    for having test suite logs in additional separate files.
> > 3. Remove the dependence on the settings module which enhances the
> >    usefulness of the logger module, as it can now be imported in more
> >    modules.
> >
> > The execution stages and the files to log to are the same for all DTS
> > loggers. To achieve this, we have one DTS root logger which should be
> > used for handling stage switching and all other loggers are children of
> > this DTS root logger. The DTS root logger is the one where we change the
> > behavior of all loggers (the stage and which files to log to) and the
> > child loggers just log messages under a different name.
> >
> > Signed-off-by: Juraj Linkeš <juraj.linkes@pantheon.tech>
> > ---
> >  dts/framework/logger.py                       | 235 +++++++++++-------
> >  dts/framework/remote_session/__init__.py      |   6 +-
> >  .../interactive_remote_session.py             |   6 +-
> >  .../remote_session/interactive_shell.py       |   6 +-
> >  .../remote_session/remote_session.py          |   8 +-
> >  dts/framework/runner.py                       |  19 +-
> >  dts/framework/test_result.py                  |   6 +-
> >  dts/framework/test_suite.py                   |   6 +-
> >  dts/framework/testbed_model/node.py           |  11 +-
> >  dts/framework/testbed_model/os_session.py     |   7 +-
> >  .../traffic_generator/traffic_generator.py    |   6 +-
> >  dts/main.py                                   |   1 -
> >  12 files changed, 183 insertions(+), 134 deletions(-)
> >
> > diff --git a/dts/framework/logger.py b/dts/framework/logger.py
> > index cfa6e8cd72..568edad82d 100644
> > --- a/dts/framework/logger.py
> > +++ b/dts/framework/logger.py
> > @@ -5,141 +5,186 @@
> >
> >  """DTS logger module.
> >
> > -DTS framework and TestSuite logs are saved in different log files.
> > +The module provides several additional features:
> > +
> > +    * The storage of DTS execution stages,
> > +    * Logging to console, a human-readable log file and a machine-readable log file,
> > +    * Optional log files for specific stages.
> >  """
> >
> >  import logging
> > -import os.path
> > -from typing import TypedDict
> > +from enum import auto
> > +from logging import FileHandler, StreamHandler
> > +from pathlib import Path
> > +from typing import ClassVar
> >
> > -from .settings import SETTINGS
> > +from .utils import StrEnum
> >
> >  date_fmt = "%Y/%m/%d %H:%M:%S"
> > -stream_fmt = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
> > +stream_fmt = "%(asctime)s - %(stage)s - %(name)s - %(levelname)s - %(message)s"
> > +dts_root_logger_name = "dts"
> > +
> > +
> > +class DtsStage(StrEnum):
> > +    """The DTS execution stage."""
> >
> > +    #:
> > +    pre_execution = auto()
> > +    #:
> > +    execution = auto()
> > +    #:
> > +    build_target = auto()
> > +    #:
> > +    suite = auto()
> > +    #:
> > +    post_execution = auto()
> >
> > -class DTSLOG(logging.LoggerAdapter):
> > -    """DTS logger adapter class for framework and testsuites.
> >
> > -    The :option:`--verbose` command line argument and the :envvar:`DTS_VERBOSE` environment
> > -    variable control the verbosity of output. If enabled, all messages will be emitted to the
> > -    console.
> > +class DTSLogger(logging.Logger):
> > +    """The DTS logger class.
> >
> > -    The :option:`--output` command line argument and the :envvar:`DTS_OUTPUT_DIR` environment
> > -    variable modify the directory where the logs will be stored.
> > +    The class extends the :class:`~logging.Logger` class to add the DTS execution stage information
> > +    to log records. The stage is common to all loggers, so it's stored in a class variable.
> >
> > -    Attributes:
> > -        node: The additional identifier. Currently unused.
> > -        sh: The handler which emits logs to console.
> > -        fh: The handler which emits logs to a file.
> > -        verbose_fh: Just as fh, but logs with a different, more verbose, format.
> > +    Any time we switch to a new stage, we have the ability to log to an additional log file along
> > +    with a supplementary log file with machine-readable format. These two log files are used until
> > +    a new stage switch occurs. This is useful mainly for logging per test suite.
> >      """
> >
> > -    _logger: logging.Logger
> > -    node: str
> > -    sh: logging.StreamHandler
> > -    fh: logging.FileHandler
> > -    verbose_fh: logging.FileHandler
> > +    _stage: ClassVar[DtsStage] = DtsStage.pre_execution
> > +    _extra_file_handlers: list[FileHandler] = []
> >
> > -    def __init__(self, logger: logging.Logger, node: str = "suite"):
> > -        """Extend the constructor with additional handlers.
> > +    def __init__(self, *args, **kwargs):
> > +        """Extend the constructor with extra file handlers."""
> > +        self._extra_file_handlers = []
> > +        super().__init__(*args, **kwargs)
> >
> > -        One handler logs to the console, the other one to a file, with either a regular or verbose
> > -        format.
> > +    def makeRecord(self, *args, **kwargs):
>
> Is the return type annotation here skipped because of the `:meta private:`?
>

Basically. We're modifying a method defined elsewhere, but there's no
harm in adding the return type.

> > +        """Generates a record with additional stage information.
> >
> > -        Args:
> > -            logger: The logger from which to create the logger adapter.
> > -            node: An additional identifier. Currently unused.
> > +        This is the default method for the :class:`~logging.Logger` class. We extend it
> > +        to add stage information to the record.
> > +
> > +        :meta private:
> > +
> > +        Returns:
> > +            record: The generated record with the stage information.
> >          """
> > -        self._logger = logger
> > -        # 1 means log everything, this will be used by file handlers if their level
> > -        # is not set
> > -        self._logger.setLevel(1)
> > +        record = super().makeRecord(*args, **kwargs)
> > +        record.stage = DTSLogger._stage
> > +        return record
> > +
> > +    def add_dts_root_logger_handlers(self, verbose: bool, output_dir: str) -> None:
> > +        """Add logger handlers to the DTS root logger.
> > +
> > +        This method should be called only on the DTS root logger.
> > +        The log records from child loggers will propagate to these handlers.
> > +
> > +        Three handlers are added:
> >
> > -        self.node = node
> > +            * A console handler,
> > +            * A file handler,
> > +            * A supplementary file handler with machine-readable logs
> > +              containing more debug information.
> >
> > -        # add handler to emit to stdout
> > -        sh = logging.StreamHandler()
> > +        All log messages will be logged to files. The log level of the console handler
> > +        is configurable with `verbose`.
> > +
> > +        Args:
> > +            verbose: If :data:`True`, log all messages to the console.
> > +                If :data:`False`, log to console with the :data:`logging.INFO` level.
> > +            output_dir: The directory where the log files will be located.
> > +                The names of the log files correspond to the name of the logger instance.
> > +        """
> > +        self.setLevel(1)
> > +
> > +        sh = StreamHandler()
> >          sh.setFormatter(logging.Formatter(stream_fmt, date_fmt))
> > -        sh.setLevel(logging.INFO)  # console handler default level
> > +        if not verbose:
> > +            sh.setLevel(logging.INFO)
> > +        self.addHandler(sh)
> >
> > -        if SETTINGS.verbose is True:
> > -            sh.setLevel(logging.DEBUG)
> > +        self._add_file_handlers(Path(output_dir, self.name))
> >
> > -        self._logger.addHandler(sh)
> > -        self.sh = sh
> > +    def set_stage(self, stage: DtsStage, log_file_path: Path | None = None) -> None:
> > +        """Set the DTS execution stage and optionally log to files.
> >
> > -        # prepare the output folder
> > -        if not os.path.exists(SETTINGS.output_dir):
> > -            os.mkdir(SETTINGS.output_dir)
> > +        Set the DTS execution stage of the DTSLog class and optionally add
> > +        file handlers to the instance if the log file name is provided.
> >
> > -        logging_path_prefix = os.path.join(SETTINGS.output_dir, node)
> > +        The file handlers log all messages. One is a regular human-readable log file and
> > +        the other one is a machine-readable log file with extra debug information.
> >
> > -        fh = logging.FileHandler(f"{logging_path_prefix}.log")
> > -        fh.setFormatter(
> > -            logging.Formatter(
> > -                fmt="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
> > -                datefmt=date_fmt,
> > -            )
> > -        )
> > +        Args:
> > +            stage: The DTS stage to set.
> > +            log_file_path: An optional path of the log file to use. This should be a full path
> > +                (either relative or absolute) without suffix (which will be appended).
> > +        """
> > +        self._remove_extra_file_handlers()
> > +
> > +        if DTSLogger._stage != stage:
> > +            self.info(f"Moving from stage '{DTSLogger._stage}' to stage '{stage}'.")
> > +            DTSLogger._stage = stage
> > +
> > +        if log_file_path:
> > +            self._extra_file_handlers.extend(self._add_file_handlers(log_file_path))
> > +
> > +    def _add_file_handlers(self, log_file_path: Path) -> list[FileHandler]:
> > +        """Add file handlers to the DTS root logger.
> > +
> > +        Add two type of file handlers:
> > +
> > +            * A regular file handler with suffix ".log",
> > +            * A machine-readable file handler with suffix ".verbose.log".
> > +              This format provides extensive information for debugging and detailed analysis.
> > +
> > +        Args:
> > +            log_file_path: The full path to the log file without suffix.
> > +
> > +        Returns:
> > +            The newly created file handlers.
> >
> > -        self._logger.addHandler(fh)
> > -        self.fh = fh
> > +        """
> > +        fh = FileHandler(f"{log_file_path}.log")
> > +        fh.setFormatter(logging.Formatter(stream_fmt, date_fmt))
> > +        self.addHandler(fh)
> >
> > -        # This outputs EVERYTHING, intended for post-mortem debugging
> > -        # Also optimized for processing via AWK (awk -F '|' ...)
> > -        verbose_fh = logging.FileHandler(f"{logging_path_prefix}.verbose.log")
> > +        verbose_fh = FileHandler(f"{log_file_path}.verbose.log")
> >          verbose_fh.setFormatter(
> >              logging.Formatter(
> > -                fmt="%(asctime)s|%(name)s|%(levelname)s|%(pathname)s|%(lineno)d|"
> > +                "%(asctime)s|%(stage)s|%(name)s|%(levelname)s|%(pathname)s|%(lineno)d|"
> >                  "%(funcName)s|%(process)d|%(thread)d|%(threadName)s|%(message)s",
> >                  datefmt=date_fmt,
> >              )
> >          )
> > +        self.addHandler(verbose_fh)
> >
> > -        self._logger.addHandler(verbose_fh)
> > -        self.verbose_fh = verbose_fh
> > -
> > -        super(DTSLOG, self).__init__(self._logger, dict(node=self.node))
> > -
> > -    def logger_exit(self) -> None:
> > -        """Remove the stream handler and the logfile handler."""
> > -        for handler in (self.sh, self.fh, self.verbose_fh):
> > -            handler.flush()
> > -            self._logger.removeHandler(handler)
> > -
> > -
> > -class _LoggerDictType(TypedDict):
> > -    logger: DTSLOG
> > -    name: str
> > -    node: str
> > -
> > +        return [fh, verbose_fh]
> >
> > -# List for saving all loggers in use
> > -_Loggers: list[_LoggerDictType] = []
> > +    def _remove_extra_file_handlers(self) -> None:
> > +        """Remove any extra file handlers that have been added to the logger."""
> > +        if self._extra_file_handlers:
> > +            for extra_file_handler in self._extra_file_handlers:
> > +                self.removeHandler(extra_file_handler)
> >
> > +            self._extra_file_handlers = []
> >
> > -def getLogger(name: str, node: str = "suite") -> DTSLOG:
> > -    """Get DTS logger adapter identified by name and node.
> >
> > -    An existing logger will be returned if one with the exact name and node already exists.
> > -    A new one will be created and stored otherwise.
> > +def get_dts_logger(name: str = None) -> DTSLogger:
> > +    """Return a DTS logger instance identified by `name`.
> >
> >      Args:
> > -        name: The name of the logger.
> > -        node: An additional identifier for the logger.
> > +        name: If :data:`None`, return the DTS root logger.
> > +            If specified, return a child of the DTS root logger.
> >
> >      Returns:
> > -        A logger uniquely identified by both name and node.
> > +         The DTS root logger or a child logger identified by `name`.
> >      """
> > -    global _Loggers
> > -    # return saved logger
> > -    logger: _LoggerDictType
> > -    for logger in _Loggers:
> > -        if logger["name"] == name and logger["node"] == node:
> > -            return logger["logger"]
> > -
> > -    # return new logger
> > -    dts_logger: DTSLOG = DTSLOG(logging.getLogger(name), node)
> > -    _Loggers.append({"logger": dts_logger, "name": name, "node": node})
> > -    return dts_logger
> > +    logging.setLoggerClass(DTSLogger)
> > +    if name:
> > +        name = f"{dts_root_logger_name}.{name}"
> > +    else:
> > +        name = dts_root_logger_name
> > +    logger = logging.getLogger(name)
> > +    logging.setLoggerClass(logging.Logger)
>
> What's the benefit of setting the logger class back to logging.Logger
> here? Is the idea basically that if someone wanted to use the logging
> module we shouldn't implicitly make them use our DTSLogger?
>

Yes. We should actually set it to whatever was there before (it may
not be logging.Logger), so I'll change that.+


> > +    return logger  # type: ignore[return-value]
> > diff --git a/dts/framework/remote_session/__init__.py b/dts/framework/remote_session/__init__.py
> > index 51a01d6b5e..1910c81c3c 100644
> > --- a/dts/framework/remote_session/__init__.py
> > +++ b/dts/framework/remote_session/__init__.py
> > @@ -15,7 +15,7 @@
> >  # pylama:ignore=W0611
> >
> >  from framework.config import NodeConfiguration
> > -from framework.logger import DTSLOG
> > +from framework.logger import DTSLogger
> >
> >  from .interactive_remote_session import InteractiveRemoteSession
> >  from .interactive_shell import InteractiveShell
> > @@ -26,7 +26,7 @@
> >
> >
> >  def create_remote_session(
> > -    node_config: NodeConfiguration, name: str, logger: DTSLOG
> > +    node_config: NodeConfiguration, name: str, logger: DTSLogger
> >  ) -> RemoteSession:
> >      """Factory for non-interactive remote sessions.
> >
> > @@ -45,7 +45,7 @@ def create_remote_session(
> >
> >
> >  def create_interactive_session(
> > -    node_config: NodeConfiguration, logger: DTSLOG
> > +    node_config: NodeConfiguration, logger: DTSLogger
> >  ) -> InteractiveRemoteSession:
> >      """Factory for interactive remote sessions.
> >
> > diff --git a/dts/framework/remote_session/interactive_remote_session.py b/dts/framework/remote_session/interactive_remote_session.py
> > index 1cc82e3377..c50790db79 100644
> > --- a/dts/framework/remote_session/interactive_remote_session.py
> > +++ b/dts/framework/remote_session/interactive_remote_session.py
> > @@ -16,7 +16,7 @@
> >
> >  from framework.config import NodeConfiguration
> >  from framework.exception import SSHConnectionError
> > -from framework.logger import DTSLOG
> > +from framework.logger import DTSLogger
> >
> >
> >  class InteractiveRemoteSession:
> > @@ -50,11 +50,11 @@ class InteractiveRemoteSession:
> >      username: str
> >      password: str
> >      session: SSHClient
> > -    _logger: DTSLOG
> > +    _logger: DTSLogger
> >      _node_config: NodeConfiguration
> >      _transport: Transport | None
> >
> > -    def __init__(self, node_config: NodeConfiguration, logger: DTSLOG) -> None:
> > +    def __init__(self, node_config: NodeConfiguration, logger: DTSLogger) -> None:
> >          """Connect to the node during initialization.
> >
> >          Args:
> > diff --git a/dts/framework/remote_session/interactive_shell.py b/dts/framework/remote_session/interactive_shell.py
> > index b158f963b6..5cfe202e15 100644
> > --- a/dts/framework/remote_session/interactive_shell.py
> > +++ b/dts/framework/remote_session/interactive_shell.py
> > @@ -20,7 +20,7 @@
> >
> >  from paramiko import Channel, SSHClient, channel  # type: ignore[import]
> >
> > -from framework.logger import DTSLOG
> > +from framework.logger import DTSLogger
> >  from framework.settings import SETTINGS
> >
> >
> > @@ -38,7 +38,7 @@ class InteractiveShell(ABC):
> >      _stdin: channel.ChannelStdinFile
> >      _stdout: channel.ChannelFile
> >      _ssh_channel: Channel
> > -    _logger: DTSLOG
> > +    _logger: DTSLogger
> >      _timeout: float
> >      _app_args: str
> >
> > @@ -61,7 +61,7 @@ class InteractiveShell(ABC):
> >      def __init__(
> >          self,
> >          interactive_session: SSHClient,
> > -        logger: DTSLOG,
> > +        logger: DTSLogger,
> >          get_privileged_command: Callable[[str], str] | None,
> >          app_args: str = "",
> >          timeout: float = SETTINGS.timeout,
> > diff --git a/dts/framework/remote_session/remote_session.py b/dts/framework/remote_session/remote_session.py
> > index 2059f9a981..a69dc99400 100644
> > --- a/dts/framework/remote_session/remote_session.py
> > +++ b/dts/framework/remote_session/remote_session.py
> > @@ -9,14 +9,13 @@
> >  the structure of the result of a command execution.
> >  """
> >
> > -
> >  import dataclasses
> >  from abc import ABC, abstractmethod
> >  from pathlib import PurePath
> >
> >  from framework.config import NodeConfiguration
> >  from framework.exception import RemoteCommandExecutionError
> > -from framework.logger import DTSLOG
> > +from framework.logger import DTSLogger
> >  from framework.settings import SETTINGS
> >
> >
> > @@ -75,14 +74,14 @@ class RemoteSession(ABC):
> >      username: str
> >      password: str
> >      history: list[CommandResult]
> > -    _logger: DTSLOG
> > +    _logger: DTSLogger
> >      _node_config: NodeConfiguration
> >
> >      def __init__(
> >          self,
> >          node_config: NodeConfiguration,
> >          session_name: str,
> > -        logger: DTSLOG,
> > +        logger: DTSLogger,
> >      ):
> >          """Connect to the node during initialization.
> >
> > @@ -181,7 +180,6 @@ def close(self, force: bool = False) -> None:
> >          Args:
> >              force: Force the closure of the connection. This may not clean up all resources.
> >          """
> > -        self._logger.logger_exit()
> >          self._close(force)
> >
> >      @abstractmethod
> > diff --git a/dts/framework/runner.py b/dts/framework/runner.py
> > index f58b0adc13..035e3368ef 100644
> > --- a/dts/framework/runner.py
> > +++ b/dts/framework/runner.py
> > @@ -19,9 +19,10 @@
> >
> >  import importlib
> >  import inspect
> > -import logging
> > +import os
> >  import re
> >  import sys
> > +from pathlib import Path
> >  from types import MethodType
> >  from typing import Iterable
> >
> > @@ -38,7 +39,7 @@
> >      SSHTimeoutError,
> >      TestCaseVerifyError,
> >  )
> > -from .logger import DTSLOG, getLogger
> > +from .logger import DTSLogger, DtsStage, get_dts_logger
> >  from .settings import SETTINGS
> >  from .test_result import (
> >      BuildTargetResult,
> > @@ -73,7 +74,7 @@ class DTSRunner:
> >      """
> >
> >      _configuration: Configuration
> > -    _logger: DTSLOG
> > +    _logger: DTSLogger
> >      _result: DTSResult
> >      _test_suite_class_prefix: str
> >      _test_suite_module_prefix: str
> > @@ -83,7 +84,10 @@ class DTSRunner:
> >      def __init__(self):
> >          """Initialize the instance with configuration, logger, result and string constants."""
> >          self._configuration = load_config()
> > -        self._logger = getLogger("DTSRunner")
> > +        self._logger = get_dts_logger()
> > +        if not os.path.exists(SETTINGS.output_dir):
> > +            os.makedirs(SETTINGS.output_dir)
> > +        self._logger.add_dts_root_logger_handlers(SETTINGS.verbose, SETTINGS.output_dir)
> >          self._result = DTSResult(self._logger)
> >          self._test_suite_class_prefix = "Test"
> >          self._test_suite_module_prefix = "tests.TestSuite_"
> > @@ -137,6 +141,7 @@ def run(self):
> >
> >              # for all Execution sections
> >              for execution in self._configuration.executions:
> > +                self._logger.set_stage(DtsStage.execution)
>
> This ends up getting set twice in short succession which of course
> doesn't functionally cause a problem, but I don't exactly see the
> point of setting it twice.

I'm not sure what you're referring to. The stage is only set once at
the start of each execution (and more generally, at the start of each
stage). It's also set in each finally block to properly mark the
cleanup of that stage. There are two finally blocks in the execution
stage where that could happen, but otherwise it should be set exactly
once.

>  We could either set it here or set it in
> the _run_execution, but i think it makes more sense to set it in the
> _run_execution method as that is the method where we are doing the
> setup, here we are only initializing the nodes which is still in a
> sense "pre execution."

Init nodes was pre-execution before the patch. I've moved it to
execution because of two reasons:
1. The nodes are actually defined per-execution. It just made more
sense to move them to execution. Also, if an error happens while
connecting to a node, we don't want to abort the whole DTS run, just
the execution, as the next execution could be connecting to a
different node.
2. We're not just doing node init here, we're also discovering which
test cases to run. This is essential to do as soon as possible (before
anything else happens in the execution, such as connecting the nodes)
so that we can mark blocked test cases in case of an error. Test case
discovery is definitely part of each execution and putting node init
after that was a natural consequence. There's an argument for
discovering test cases of all executions before actually running any
of the executions. It's certainly doable, but the code doesn't look
that good (when not modifying the original execution config (which we
shouldn't do) - I've tried) and there's actually not much of a point
to do it this way, the result is almost the same. Where it makes a
difference is when there's an error in test suite configuration and
later executions - the user would have to wait for the previous
execution to fully run to discover the error).

>
>
> >                  self._logger.info(
> >                      f"Running execution with SUT '{execution.system_under_test_node.name}'."
> >                  )
> > @@ -164,6 +169,7 @@ def run(self):
> >
> >          finally:
> >              try:
> > +                self._logger.set_stage(DtsStage.post_execution)
> >                  for node in (sut_nodes | tg_nodes).values():
> >                      node.close()
> >                  self._result.update_teardown(Result.PASS)
> > @@ -419,6 +425,7 @@ def _run_execution(
> >
> >          finally:
> >              try:
> > +                self._logger.set_stage(DtsStage.execution)
> >                  sut_node.tear_down_execution()
> >                  execution_result.update_teardown(Result.PASS)
> >              except Exception as e:
> > @@ -447,6 +454,7 @@ def _run_build_target(
> >                  with the current build target.
> >              test_suites_with_cases: The test suites with test cases to run.
> >          """
> > +        self._logger.set_stage(DtsStage.build_target)
> >          self._logger.info(f"Running build target '{build_target.name}'.")
> >
> >          try:
> > @@ -463,6 +471,7 @@ def _run_build_target(
> >
> >          finally:
> >              try:
> > +                self._logger.set_stage(DtsStage.build_target)
> >                  sut_node.tear_down_build_target()
> >                  build_target_result.update_teardown(Result.PASS)
> >              except Exception as e:
> > @@ -535,6 +544,7 @@ def _run_test_suite(
> >              BlockingTestSuiteError: If a blocking test suite fails.
> >          """
> >          test_suite_name = test_suite_with_cases.test_suite_class.__name__
> > +        self._logger.set_stage(DtsStage.suite, Path(SETTINGS.output_dir, test_suite_name))
> >          test_suite = test_suite_with_cases.test_suite_class(sut_node, tg_node)
> >          try:
> >              self._logger.info(f"Starting test suite setup: {test_suite_name}")
> > @@ -683,5 +693,4 @@ def _exit_dts(self) -> None:
> >          if self._logger:
> >              self._logger.info("DTS execution has ended.")
> >
> > -        logging.shutdown()
> >          sys.exit(self._result.get_return_code())
> > diff --git a/dts/framework/test_result.py b/dts/framework/test_result.py
> > index eedb2d20ee..28f84fd793 100644
> > --- a/dts/framework/test_result.py
> > +++ b/dts/framework/test_result.py
> > @@ -42,7 +42,7 @@
> >      TestSuiteConfig,
> >  )
> >  from .exception import DTSError, ErrorSeverity
> > -from .logger import DTSLOG
> > +from .logger import DTSLogger
> >  from .settings import SETTINGS
> >  from .test_suite import TestSuite
> >
> > @@ -237,13 +237,13 @@ class DTSResult(BaseResult):
> >      """
> >
> >      dpdk_version: str | None
> > -    _logger: DTSLOG
> > +    _logger: DTSLogger
> >      _errors: list[Exception]
> >      _return_code: ErrorSeverity
> >      _stats_result: Union["Statistics", None]
> >      _stats_filename: str
> >
> > -    def __init__(self, logger: DTSLOG):
> > +    def __init__(self, logger: DTSLogger):
> >          """Extend the constructor with top-level specifics.
> >
> >          Args:
> > diff --git a/dts/framework/test_suite.py b/dts/framework/test_suite.py
> > index f9fe88093e..365f80e21a 100644
> > --- a/dts/framework/test_suite.py
> > +++ b/dts/framework/test_suite.py
> > @@ -21,7 +21,7 @@
> >  from scapy.packet import Packet, Padding  # type: ignore[import]
> >
> >  from .exception import TestCaseVerifyError
> > -from .logger import DTSLOG, getLogger
> > +from .logger import DTSLogger, get_dts_logger
> >  from .testbed_model import Port, PortLink, SutNode, TGNode
> >  from .utils import get_packet_summaries
> >
> > @@ -61,7 +61,7 @@ class TestSuite(object):
> >      #: Whether the test suite is blocking. A failure of a blocking test suite
> >      #: will block the execution of all subsequent test suites in the current build target.
> >      is_blocking: ClassVar[bool] = False
> > -    _logger: DTSLOG
> > +    _logger: DTSLogger
> >      _port_links: list[PortLink]
> >      _sut_port_ingress: Port
> >      _sut_port_egress: Port
> > @@ -88,7 +88,7 @@ def __init__(
> >          """
> >          self.sut_node = sut_node
> >          self.tg_node = tg_node
> > -        self._logger = getLogger(self.__class__.__name__)
> > +        self._logger = get_dts_logger(self.__class__.__name__)
> >          self._port_links = []
> >          self._process_links()
> >          self._sut_port_ingress, self._tg_port_egress = (
> > diff --git a/dts/framework/testbed_model/node.py b/dts/framework/testbed_model/node.py
> > index 1a55fadf78..74061f6262 100644
> > --- a/dts/framework/testbed_model/node.py
> > +++ b/dts/framework/testbed_model/node.py
> > @@ -23,7 +23,7 @@
> >      NodeConfiguration,
> >  )
> >  from framework.exception import ConfigurationError
> > -from framework.logger import DTSLOG, getLogger
> > +from framework.logger import DTSLogger, get_dts_logger
> >  from framework.settings import SETTINGS
> >
> >  from .cpu import (
> > @@ -63,7 +63,7 @@ class Node(ABC):
> >      name: str
> >      lcores: list[LogicalCore]
> >      ports: list[Port]
> > -    _logger: DTSLOG
> > +    _logger: DTSLogger
> >      _other_sessions: list[OSSession]
> >      _execution_config: ExecutionConfiguration
> >      virtual_devices: list[VirtualDevice]
> > @@ -82,7 +82,7 @@ def __init__(self, node_config: NodeConfiguration):
> >          """
> >          self.config = node_config
> >          self.name = node_config.name
> > -        self._logger = getLogger(self.name)
> > +        self._logger = get_dts_logger(self.name)
> >          self.main_session = create_session(self.config, self.name, self._logger)
> >
> >          self._logger.info(f"Connected to node: {self.name}")
> > @@ -189,7 +189,7 @@ def create_session(self, name: str) -> OSSession:
> >          connection = create_session(
> >              self.config,
> >              session_name,
> > -            getLogger(session_name, node=self.name),
> > +            get_dts_logger(session_name),
> >          )
> >          self._other_sessions.append(connection)
> >          return connection
> > @@ -299,7 +299,6 @@ def close(self) -> None:
> >              self.main_session.close()
> >          for session in self._other_sessions:
> >              session.close()
> > -        self._logger.logger_exit()
> >
> >      @staticmethod
> >      def skip_setup(func: Callable[..., Any]) -> Callable[..., Any]:
> > @@ -314,7 +313,7 @@ def skip_setup(func: Callable[..., Any]) -> Callable[..., Any]:
> >              return func
> >
> >
> > -def create_session(node_config: NodeConfiguration, name: str, logger: DTSLOG) -> OSSession:
> > +def create_session(node_config: NodeConfiguration, name: str, logger: DTSLogger) -> OSSession:
> >      """Factory for OS-aware sessions.
> >
> >      Args:
> > diff --git a/dts/framework/testbed_model/os_session.py b/dts/framework/testbed_model/os_session.py
> > index ac6bb5e112..6983aa4a77 100644
> > --- a/dts/framework/testbed_model/os_session.py
> > +++ b/dts/framework/testbed_model/os_session.py
> > @@ -21,7 +21,6 @@
> >      the :attr:`~.node.Node.main_session` translates that to ``rm -rf`` if the node's OS is Linux
> >      and other commands for other OSs. It also translates the path to match the underlying OS.
> >  """
> > -
> >  from abc import ABC, abstractmethod
> >  from collections.abc import Iterable
> >  from ipaddress import IPv4Interface, IPv6Interface
> > @@ -29,7 +28,7 @@
> >  from typing import Type, TypeVar, Union
> >
> >  from framework.config import Architecture, NodeConfiguration, NodeInfo
> > -from framework.logger import DTSLOG
> > +from framework.logger import DTSLogger
> >  from framework.remote_session import (
> >      CommandResult,
> >      InteractiveRemoteSession,
> > @@ -62,7 +61,7 @@ class OSSession(ABC):
> >
> >      _config: NodeConfiguration
> >      name: str
> > -    _logger: DTSLOG
> > +    _logger: DTSLogger
> >      remote_session: RemoteSession
> >      interactive_session: InteractiveRemoteSession
> >
> > @@ -70,7 +69,7 @@ def __init__(
> >          self,
> >          node_config: NodeConfiguration,
> >          name: str,
> > -        logger: DTSLOG,
> > +        logger: DTSLogger,
> >      ):
> >          """Initialize the OS-aware session.
> >
> > diff --git a/dts/framework/testbed_model/traffic_generator/traffic_generator.py b/dts/framework/testbed_model/traffic_generator/traffic_generator.py
> > index c49fbff488..d86d7fb532 100644
> > --- a/dts/framework/testbed_model/traffic_generator/traffic_generator.py
> > +++ b/dts/framework/testbed_model/traffic_generator/traffic_generator.py
> > @@ -13,7 +13,7 @@
> >  from scapy.packet import Packet  # type: ignore[import]
> >
> >  from framework.config import TrafficGeneratorConfig
> > -from framework.logger import DTSLOG, getLogger
> > +from framework.logger import DTSLogger, get_dts_logger
> >  from framework.testbed_model.node import Node
> >  from framework.testbed_model.port import Port
> >  from framework.utils import get_packet_summaries
> > @@ -28,7 +28,7 @@ class TrafficGenerator(ABC):
> >
> >      _config: TrafficGeneratorConfig
> >      _tg_node: Node
> > -    _logger: DTSLOG
> > +    _logger: DTSLogger
> >
> >      def __init__(self, tg_node: Node, config: TrafficGeneratorConfig):
> >          """Initialize the traffic generator.
> > @@ -39,7 +39,7 @@ def __init__(self, tg_node: Node, config: TrafficGeneratorConfig):
> >          """
> >          self._config = config
> >          self._tg_node = tg_node
> > -        self._logger = getLogger(f"{self._tg_node.name} {self._config.traffic_generator_type}")
> > +        self._logger = get_dts_logger(f"{self._tg_node.name} {self._config.traffic_generator_type}")
> >
> >      def send_packet(self, packet: Packet, port: Port) -> None:
> >          """Send `packet` and block until it is fully sent.
> > diff --git a/dts/main.py b/dts/main.py
> > index 1ffe8ff81f..d30c164b95 100755
> > --- a/dts/main.py
> > +++ b/dts/main.py
> > @@ -30,5 +30,4 @@ def main() -> None:
> >
> >  # Main program begins here
> >  if __name__ == "__main__":
> > -    logging.raiseExceptions = True
> >      main()
> > --
> > 2.34.1
> >

^ permalink raw reply	[flat|nested] 28+ messages in thread

* Re: [PATCH v2 3/7] dts: filter test suites in executions
  2024-02-12 16:44     ` Jeremy Spewock
@ 2024-02-14  9:55       ` Juraj Linkeš
  0 siblings, 0 replies; 28+ messages in thread
From: Juraj Linkeš @ 2024-02-14  9:55 UTC (permalink / raw)
  To: Jeremy Spewock
  Cc: thomas, Honnappa.Nagarahalli, probb, paul.szczepanek, Luca.Vizzarro, dev

On Mon, Feb 12, 2024 at 5:44 PM Jeremy Spewock <jspewock@iol.unh.edu> wrote:
>
> On Tue, Feb 6, 2024 at 9:57 AM Juraj Linkeš <juraj.linkes@pantheon.tech> wrote:
> >
> > We're currently filtering which test cases to run after some setup
> > steps, such as DPDK build, have already been taken. This prohibits us to
> > mark the test suites and cases that were supposed to be run as blocked
> > when an earlier setup fails, as that information is not available at
> > that time.
> >
> > To remedy this, move the filtering to the beginning of each execution.
> > This is the first action taken in each execution and if we can't filter
> > the test cases, such as due to invalid inputs, we abort the whole
> > execution. No test suites nor cases will be marked as blocked as we
> > don't know which were supposed to be run.
> >
> > On top of that, the filtering takes place in the TestSuite class, which
> > should only concern itself with test suite and test case logic, not the
> > processing behind the scenes. The logic has been moved to DTSRunner
> > which should do all the processing needed to run test suites.
> >
> > The filtering itself introduces a few changes/assumptions which are more
> > sensible than before:
> > 1. Assumption: There is just one TestSuite child class in each test
> >    suite module. This was an implicit assumption before as we couldn't
> >    specify the TestSuite classes in the test run configuration, just the
> >    modules. The name of the TestSuite child class starts with "Test" and
> >    then corresponds to the name of the module with CamelCase naming.
> > 2. Unknown test cases specified both in the test run configuration and
> >    the environment variable/command line argument are no longer silently
> >    ignored. This is a quality of life improvement for users, as they
> >    could easily be not aware of the silent ignoration.
> >
> > Also, a change in the code results in pycodestyle warning and error:
> > [E] E203 whitespace before ':'
> > [W] W503 line break before binary operator
> >
> > These two are not PEP8 compliant, so they're disabled.
> >
> > Signed-off-by: Juraj Linkeš <juraj.linkes@pantheon.tech>
> > ---
> >  dts/framework/config/__init__.py           |  24 +-
> >  dts/framework/config/conf_yaml_schema.json |   2 +-
> >  dts/framework/runner.py                    | 426 +++++++++++++++------
> >  dts/framework/settings.py                  |   3 +-
> >  dts/framework/test_result.py               |  34 ++
> >  dts/framework/test_suite.py                |  85 +---
> >  dts/pyproject.toml                         |   3 +
> >  dts/tests/TestSuite_smoke_tests.py         |   2 +-
> >  8 files changed, 382 insertions(+), 197 deletions(-)
> >
> > diff --git a/dts/framework/config/__init__.py b/dts/framework/config/__init__.py
> > index 62eded7f04..c6a93b3b89 100644
> > --- a/dts/framework/config/__init__.py
> > +++ b/dts/framework/config/__init__.py
> > @@ -36,7 +36,7 @@
> >  import json
> >  import os.path
> >  import pathlib
> > -from dataclasses import dataclass
> > +from dataclasses import dataclass, fields
> >  from enum import auto, unique
> >  from typing import Union
> >
> > @@ -506,6 +506,28 @@ def from_dict(
> >              vdevs=vdevs,
> >          )
> >
> > +    def copy_and_modify(self, **kwargs) -> "ExecutionConfiguration":
> > +        """Create a shallow copy with any of the fields modified.
> > +
> > +        The only new data are those passed to this method.
> > +        The rest are copied from the object's fields calling the method.
> > +
> > +        Args:
> > +            **kwargs: The names and types of keyword arguments are defined
> > +                by the fields of the :class:`ExecutionConfiguration` class.
> > +
> > +        Returns:
> > +            The copied and modified execution configuration.
> > +        """
> > +        new_config = {}
> > +        for field in fields(self):
> > +            if field.name in kwargs:
> > +                new_config[field.name] = kwargs[field.name]
> > +            else:
> > +                new_config[field.name] = getattr(self, field.name)
> > +
> > +        return ExecutionConfiguration(**new_config)
> > +
> >
> >  @dataclass(slots=True, frozen=True)
> >  class Configuration:
> > diff --git a/dts/framework/config/conf_yaml_schema.json b/dts/framework/config/conf_yaml_schema.json
> > index 84e45fe3c2..051b079fe4 100644
> > --- a/dts/framework/config/conf_yaml_schema.json
> > +++ b/dts/framework/config/conf_yaml_schema.json
> > @@ -197,7 +197,7 @@
> >          },
> >          "cases": {
> >            "type": "array",
> > -          "description": "If specified, only this subset of test suite's test cases will be run. Unknown test cases will be silently ignored.",
> > +          "description": "If specified, only this subset of test suite's test cases will be run.",
> >            "items": {
> >              "type": "string"
> >            },
> > diff --git a/dts/framework/runner.py b/dts/framework/runner.py
> > index 933685d638..3e95cf9e26 100644
> > --- a/dts/framework/runner.py
> > +++ b/dts/framework/runner.py
> > @@ -17,17 +17,27 @@
> >  and the test case stage runs test cases individually.
> >  """
> >
> > +import importlib
> > +import inspect
> >  import logging
> > +import re
> >  import sys
> >  from types import MethodType
> > +from typing import Iterable
> >
> >  from .config import (
> >      BuildTargetConfiguration,
> > +    Configuration,
> >      ExecutionConfiguration,
> >      TestSuiteConfig,
> >      load_config,
> >  )
> > -from .exception import BlockingTestSuiteError, SSHTimeoutError, TestCaseVerifyError
> > +from .exception import (
> > +    BlockingTestSuiteError,
> > +    ConfigurationError,
> > +    SSHTimeoutError,
> > +    TestCaseVerifyError,
> > +)
> >  from .logger import DTSLOG, getLogger
> >  from .settings import SETTINGS
> >  from .test_result import (
> > @@ -37,8 +47,9 @@
> >      Result,
> >      TestCaseResult,
> >      TestSuiteResult,
> > +    TestSuiteWithCases,
> >  )
> > -from .test_suite import TestSuite, get_test_suites
> > +from .test_suite import TestSuite
> >  from .testbed_model import SutNode, TGNode
> >
> >
> > @@ -59,13 +70,23 @@ class DTSRunner:
> >          given execution, the next execution begins.
> >      """
> >
> > +    _configuration: Configuration
> >      _logger: DTSLOG
> >      _result: DTSResult
> > +    _test_suite_class_prefix: str
> > +    _test_suite_module_prefix: str
> > +    _func_test_case_regex: str
> > +    _perf_test_case_regex: str
> >
> >      def __init__(self):
> > -        """Initialize the instance with logger and result."""
> > +        """Initialize the instance with configuration, logger, result and string constants."""
> > +        self._configuration = load_config()
> >          self._logger = getLogger("DTSRunner")
> >          self._result = DTSResult(self._logger)
> > +        self._test_suite_class_prefix = "Test"
> > +        self._test_suite_module_prefix = "tests.TestSuite_"
> > +        self._func_test_case_regex = r"test_(?!perf_)"
> > +        self._perf_test_case_regex = r"test_perf_"
> >
> >      def run(self):
> >          """Run all build targets in all executions from the test run configuration.
> > @@ -106,29 +127,28 @@ def run(self):
> >          try:
> >              # check the python version of the server that runs dts
> >              self._check_dts_python_version()
> > +            self._result.update_setup(Result.PASS)
> >
> >              # for all Execution sections
> > -            for execution in load_config().executions:
> > -                sut_node = sut_nodes.get(execution.system_under_test_node.name)
> > -                tg_node = tg_nodes.get(execution.traffic_generator_node.name)
> > -
> > +            for execution in self._configuration.executions:
> > +                self._logger.info(
> > +                    f"Running execution with SUT '{execution.system_under_test_node.name}'."
> > +                )
> > +                execution_result = self._result.add_execution(execution.system_under_test_node)
> >                  try:
> > -                    if not sut_node:
> > -                        sut_node = SutNode(execution.system_under_test_node)
> > -                        sut_nodes[sut_node.name] = sut_node
> > -                    if not tg_node:
> > -                        tg_node = TGNode(execution.traffic_generator_node)
> > -                        tg_nodes[tg_node.name] = tg_node
> > -                    self._result.update_setup(Result.PASS)
> > +                    test_suites_with_cases = self._get_test_suites_with_cases(
> > +                        execution.test_suites, execution.func, execution.perf
> > +                    )
> >                  except Exception as e:
> > -                    failed_node = execution.system_under_test_node.name
> > -                    if sut_node:
> > -                        failed_node = execution.traffic_generator_node.name
> > -                    self._logger.exception(f"The Creation of node {failed_node} failed.")
> > -                    self._result.update_setup(Result.FAIL, e)
> > +                    self._logger.exception(
> > +                        f"Invalid test suite configuration found: " f"{execution.test_suites}."
> > +                    )
> > +                    execution_result.update_setup(Result.FAIL, e)
> >
> >                  else:
> > -                    self._run_execution(sut_node, tg_node, execution)
> > +                    self._connect_nodes_and_run_execution(
> > +                        sut_nodes, tg_nodes, execution, execution_result, test_suites_with_cases
> > +                    )
> >
> >          except Exception as e:
> >              self._logger.exception("An unexpected error has occurred.")
> > @@ -163,11 +183,204 @@ def _check_dts_python_version(self) -> None:
> >              )
> >              self._logger.warning("Please use Python >= 3.10 instead.")
> >
> > +    def _get_test_suites_with_cases(
> > +        self,
> > +        test_suite_configs: list[TestSuiteConfig],
> > +        func: bool,
> > +        perf: bool,
> > +    ) -> list[TestSuiteWithCases]:
> > +        """Test suites with test cases discovery.
> > +
> > +        The test suites with test cases defined in the user configuration are discovered
> > +        and stored for future use so that we don't import the modules twice and so that
> > +        the list of test suites with test cases is available for recording right away.
> > +
> > +        Args:
> > +            test_suite_configs: Test suite configurations.
> > +            func: Whether to include functional test cases in the final list.
> > +            perf: Whether to include performance test cases in the final list.
> > +
> > +        Returns:
> > +            The discovered test suites, each with test cases.
> > +        """
> > +        test_suites_with_cases = []
> > +
> > +        for test_suite_config in test_suite_configs:
> > +            test_suite_class = self._get_test_suite_class(test_suite_config.test_suite)
> > +            test_cases = []
> > +            func_test_cases, perf_test_cases = self._filter_test_cases(
> > +                test_suite_class, set(test_suite_config.test_cases + SETTINGS.test_cases)
> > +            )
> > +            if func:
> > +                test_cases.extend(func_test_cases)
> > +            if perf:
> > +                test_cases.extend(perf_test_cases)
> > +
> > +            test_suites_with_cases.append(
> > +                TestSuiteWithCases(test_suite_class=test_suite_class, test_cases=test_cases)
> > +            )
> > +
> > +        return test_suites_with_cases
> > +
> > +    def _get_test_suite_class(self, test_suite_name: str) -> type[TestSuite]:
> > +        """Find the :class:`TestSuite` class with `test_suite_name` in the corresponding module.
> > +
> > +        The method assumes that the :class:`TestSuite` class starts
> > +        with `self._test_suite_class_prefix`,
> > +        continuing with `test_suite_name` with CamelCase convention.
> > +        It also assumes there's only one test suite in each module and the module name
> > +        is `test_suite_name` prefixed with `self._test_suite_module_prefix`.
> > +
> > +        The CamelCase convention is not tested, only lowercase strings are compared.
> > +
> > +        Args:
> > +            test_suite_name: The name of the test suite to find.
> > +
> > +        Returns:
> > +            The found test suite.
> > +
> > +        Raises:
> > +            ConfigurationError: If the corresponding module is not found or
> > +                a valid :class:`TestSuite` is not found in the module.
> > +        """
> > +
> > +        def is_test_suite(object) -> bool:
> > +            """Check whether `object` is a :class:`TestSuite`.
> > +
> > +            The `object` is a subclass of :class:`TestSuite`, but not :class:`TestSuite` itself.
> > +
> > +            Args:
> > +                object: The object to be checked.
> > +
> > +            Returns:
> > +                :data:`True` if `object` is a subclass of `TestSuite`.
> > +            """
> > +            try:
> > +                if issubclass(object, TestSuite) and object is not TestSuite:
> > +                    return True
> > +            except TypeError:
> > +                return False
> > +            return False
> > +
> > +        testsuite_module_path = f"{self._test_suite_module_prefix}{test_suite_name}"
> > +        try:
> > +            test_suite_module = importlib.import_module(testsuite_module_path)
> > +        except ModuleNotFoundError as e:
> > +            raise ConfigurationError(
> > +                f"Test suite module '{testsuite_module_path}' not found."
> > +            ) from e
> > +
> > +        lowercase_suite_name = test_suite_name.replace("_", "").lower()
> > +        for class_name, class_obj in inspect.getmembers(test_suite_module, is_test_suite):
> > +            if (
> > +                class_name.startswith(self._test_suite_class_prefix)
> > +                and lowercase_suite_name == class_name[len(self._test_suite_class_prefix) :].lower()
> > +            ):
>
> Would it be simpler to instead just make lowercase_suite_name =
> f"{self._test_suite_class_prefix}{test_suite_name.replace("_",
> "").lower()}" so that you can just directly compare class_name ==
> lowercase_suite_name? Both ways should have the exact same result of
> course so it isn't important, I was just curious.
>

I've looked at how the code looks and it is better. I also changed
some of the variable names (test_suite_name -> module_name and
lowercase_suite_name -> lowercase_suite_to_find), updated the
docstring and now I'm much happier with the result.

> > +                return class_obj
> > +        raise ConfigurationError(
> > +            f"Couldn't find any valid test suites in {test_suite_module.__name__}."
> > +        )
> > +
> > +    def _filter_test_cases(
> > +        self, test_suite_class: type[TestSuite], test_cases_to_run: set[str]
> > +    ) -> tuple[list[MethodType], list[MethodType]]:
> > +        """Filter `test_cases_to_run` from `test_suite_class`.
> > +
> > +        There are two rounds of filtering if `test_cases_to_run` is not empty.
> > +        The first filters `test_cases_to_run` from all methods of `test_suite_class`.
> > +        Then the methods are separated into functional and performance test cases.
> > +        If a method doesn't match neither the functional nor performance name prefix, it's an error.
>
> I think this is a double negative but could be either "if a method
> doesn't match either ... or ..." or "if a method matches neither ...
> nor ...". I have a small preference to the second of the two options
> though because the "neither" makes the negative more clear in my mind.
>

I'll change this, thanks for the grammar fix.

> > +
> > +        Args:
> > +            test_suite_class: The class of the test suite.
> > +            test_cases_to_run: Test case names to filter from `test_suite_class`.
> > +                If empty, return all matching test cases.
> > +
> > +        Returns:
> > +            A list of test case methods that should be executed.
> > +
> > +        Raises:
> > +            ConfigurationError: If a test case from `test_cases_to_run` is not found
> > +                or it doesn't match either the functional nor performance name prefix.
> > +        """
> > +        func_test_cases = []
> > +        perf_test_cases = []
> > +        name_method_tuples = inspect.getmembers(test_suite_class, inspect.isfunction)
> > +        if test_cases_to_run:
> > +            name_method_tuples = [
> > +                (name, method) for name, method in name_method_tuples if name in test_cases_to_run
> > +            ]
> > +            if len(name_method_tuples) < len(test_cases_to_run):
> > +                missing_test_cases = test_cases_to_run - {name for name, _ in name_method_tuples}
> > +                raise ConfigurationError(
> > +                    f"Test cases {missing_test_cases} not found among methods "
> > +                    f"of {test_suite_class.__name__}."
> > +                )
> > +
> > +        for test_case_name, test_case_method in name_method_tuples:
> > +            if re.match(self._func_test_case_regex, test_case_name):
> > +                func_test_cases.append(test_case_method)
> > +            elif re.match(self._perf_test_case_regex, test_case_name):
> > +                perf_test_cases.append(test_case_method)
> > +            elif test_cases_to_run:
> > +                raise ConfigurationError(
> > +                    f"Method '{test_case_name}' doesn't match neither "
> > +                    f"a functional nor a performance test case name."
>
> Same thing here with the double negative.
>
>
>
> > +                )
> > +
> > +        return func_test_cases, perf_test_cases
> > +
> > +    def _connect_nodes_and_run_execution(
> > +        self,
> > +        sut_nodes: dict[str, SutNode],
> > +        tg_nodes: dict[str, TGNode],
> > +        execution: ExecutionConfiguration,
> > +        execution_result: ExecutionResult,
> > +        test_suites_with_cases: Iterable[TestSuiteWithCases],
> > +    ) -> None:
> > +        """Connect nodes, then continue to run the given execution.
> > +
> > +        Connect the :class:`SutNode` and the :class:`TGNode` of this `execution`.
> > +        If either has already been connected, it's going to be in either `sut_nodes` or `tg_nodes`,
> > +        respectively.
> > +        If not, connect and add the node to the respective `sut_nodes` or `tg_nodes` :class:`dict`.
> > +
> > +        Args:
> > +            sut_nodes: A dictionary storing connected/to be connected SUT nodes.
> > +            tg_nodes: A dictionary storing connected/to be connected TG nodes.
> > +            execution: An execution's test run configuration.
> > +            execution_result: The execution's result.
> > +            test_suites_with_cases: The test suites with test cases to run.
> > +        """
> > +        sut_node = sut_nodes.get(execution.system_under_test_node.name)
> > +        tg_node = tg_nodes.get(execution.traffic_generator_node.name)
> > +
> > +        try:
> > +            if not sut_node:
> > +                sut_node = SutNode(execution.system_under_test_node)
> > +                sut_nodes[sut_node.name] = sut_node
> > +            if not tg_node:
> > +                tg_node = TGNode(execution.traffic_generator_node)
> > +                tg_nodes[tg_node.name] = tg_node
> > +        except Exception as e:
> > +            failed_node = execution.system_under_test_node.name
> > +            if sut_node:
> > +                failed_node = execution.traffic_generator_node.name
> > +            self._logger.exception(f"The Creation of node {failed_node} failed.")
> > +            execution_result.update_setup(Result.FAIL, e)
> > +
> > +        else:
> > +            self._run_execution(
> > +                sut_node, tg_node, execution, execution_result, test_suites_with_cases
> > +            )
> > +
> >      def _run_execution(
> >          self,
> >          sut_node: SutNode,
> >          tg_node: TGNode,
> >          execution: ExecutionConfiguration,
> > +        execution_result: ExecutionResult,
> > +        test_suites_with_cases: Iterable[TestSuiteWithCases],
> >      ) -> None:
> >          """Run the given execution.
> >
> > @@ -178,11 +391,11 @@ def _run_execution(
> >              sut_node: The execution's SUT node.
> >              tg_node: The execution's TG node.
> >              execution: An execution's test run configuration.
> > +            execution_result: The execution's result.
> > +            test_suites_with_cases: The test suites with test cases to run.
> >          """
> >          self._logger.info(f"Running execution with SUT '{execution.system_under_test_node.name}'.")
> > -        execution_result = self._result.add_execution(sut_node.config)
> >          execution_result.add_sut_info(sut_node.node_info)
> > -
> >          try:
> >              sut_node.set_up_execution(execution)
> >              execution_result.update_setup(Result.PASS)
> > @@ -192,7 +405,10 @@ def _run_execution(
> >
> >          else:
> >              for build_target in execution.build_targets:
> > -                self._run_build_target(sut_node, tg_node, build_target, execution, execution_result)
> > +                build_target_result = execution_result.add_build_target(build_target)
> > +                self._run_build_target(
> > +                    sut_node, tg_node, build_target, build_target_result, test_suites_with_cases
> > +                )
> >
> >          finally:
> >              try:
> > @@ -207,8 +423,8 @@ def _run_build_target(
> >          sut_node: SutNode,
> >          tg_node: TGNode,
> >          build_target: BuildTargetConfiguration,
> > -        execution: ExecutionConfiguration,
> > -        execution_result: ExecutionResult,
> > +        build_target_result: BuildTargetResult,
> > +        test_suites_with_cases: Iterable[TestSuiteWithCases],
> >      ) -> None:
> >          """Run the given build target.
> >
> > @@ -220,11 +436,11 @@ def _run_build_target(
> >              sut_node: The execution's sut node.
> >              tg_node: The execution's tg node.
> >              build_target: A build target's test run configuration.
> > -            execution: The build target's execution's test run configuration.
> > -            execution_result: The execution level result object associated with the execution.
> > +            build_target_result: The build target level result object associated
> > +                with the current build target.
> > +            test_suites_with_cases: The test suites with test cases to run.
> >          """
> >          self._logger.info(f"Running build target '{build_target.name}'.")
> > -        build_target_result = execution_result.add_build_target(build_target)
> >
> >          try:
> >              sut_node.set_up_build_target(build_target)
> > @@ -236,7 +452,7 @@ def _run_build_target(
> >              build_target_result.update_setup(Result.FAIL, e)
> >
> >          else:
> > -            self._run_test_suites(sut_node, tg_node, execution, build_target_result)
> > +            self._run_test_suites(sut_node, tg_node, build_target_result, test_suites_with_cases)
> >
> >          finally:
> >              try:
> > @@ -250,10 +466,10 @@ def _run_test_suites(
> >          self,
> >          sut_node: SutNode,
> >          tg_node: TGNode,
> > -        execution: ExecutionConfiguration,
> >          build_target_result: BuildTargetResult,
> > +        test_suites_with_cases: Iterable[TestSuiteWithCases],
> >      ) -> None:
> > -        """Run the execution's (possibly a subset of) test suites using the current build target.
> > +        """Run `test_suites_with_cases` with the current build target.
> >
> >          The method assumes the build target we're testing has already been built on the SUT node.
> >          The current build target thus corresponds to the current DPDK build present on the SUT node.
> > @@ -264,22 +480,20 @@ def _run_test_suites(
> >          Args:
> >              sut_node: The execution's SUT node.
> >              tg_node: The execution's TG node.
> > -            execution: The execution's test run configuration associated
> > -                with the current build target.
> >              build_target_result: The build target level result object associated
> >                  with the current build target.
> > +            test_suites_with_cases: The test suites with test cases to run.
> >          """
> >          end_build_target = False
> > -        if not execution.skip_smoke_tests:
> > -            execution.test_suites[:0] = [TestSuiteConfig.from_dict("smoke_tests")]
> > -        for test_suite_config in execution.test_suites:
> > +        for test_suite_with_cases in test_suites_with_cases:
> > +            test_suite_result = build_target_result.add_test_suite(
> > +                test_suite_with_cases.test_suite_class.__name__
> > +            )
> >              try:
> > -                self._run_test_suite_module(
> > -                    sut_node, tg_node, execution, build_target_result, test_suite_config
> > -                )
> > +                self._run_test_suite(sut_node, tg_node, test_suite_result, test_suite_with_cases)
> >              except BlockingTestSuiteError as e:
> >                  self._logger.exception(
> > -                    f"An error occurred within {test_suite_config.test_suite}. "
> > +                    f"An error occurred within {test_suite_with_cases.test_suite_class.__name__}. "
> >                      "Skipping build target..."
> >                  )
> >                  self._result.add_error(e)
> > @@ -288,15 +502,14 @@ def _run_test_suites(
> >              if end_build_target:
> >                  break
> >
> > -    def _run_test_suite_module(
> > +    def _run_test_suite(
> >          self,
> >          sut_node: SutNode,
> >          tg_node: TGNode,
> > -        execution: ExecutionConfiguration,
> > -        build_target_result: BuildTargetResult,
> > -        test_suite_config: TestSuiteConfig,
> > +        test_suite_result: TestSuiteResult,
> > +        test_suite_with_cases: TestSuiteWithCases,
> >      ) -> None:
> > -        """Set up, execute and tear down all test suites in a single test suite module.
> > +        """Set up, execute and tear down `test_suite_with_cases`.
> >
> >          The method assumes the build target we're testing has already been built on the SUT node.
> >          The current build target thus corresponds to the current DPDK build present on the SUT node.
> > @@ -306,92 +519,79 @@ def _run_test_suite_module(
> >
> >          Record the setup and the teardown and handle failures.
> >
> > -        The test cases to execute are discovered when creating the :class:`TestSuite` object.
> > -
> >          Args:
> >              sut_node: The execution's SUT node.
> >              tg_node: The execution's TG node.
> > -            execution: The execution's test run configuration associated
> > -                with the current build target.
> > -            build_target_result: The build target level result object associated
> > -                with the current build target.
> > -            test_suite_config: Test suite test run configuration specifying the test suite module
> > -                and possibly a subset of test cases of test suites in that module.
> > +            test_suite_result: The test suite level result object associated
> > +                with the current test suite.
> > +            test_suite_with_cases: The test suite with test cases to run.
> >
> >          Raises:
> >              BlockingTestSuiteError: If a blocking test suite fails.
> >          """
> > +        test_suite_name = test_suite_with_cases.test_suite_class.__name__
> > +        test_suite = test_suite_with_cases.test_suite_class(sut_node, tg_node)
> >          try:
> > -            full_suite_path = f"tests.TestSuite_{test_suite_config.test_suite}"
> > -            test_suite_classes = get_test_suites(full_suite_path)
> > -            suites_str = ", ".join((x.__name__ for x in test_suite_classes))
> > -            self._logger.debug(f"Found test suites '{suites_str}' in '{full_suite_path}'.")
> > +            self._logger.info(f"Starting test suite setup: {test_suite_name}")
> > +            test_suite.set_up_suite()
> > +            test_suite_result.update_setup(Result.PASS)
> > +            self._logger.info(f"Test suite setup successful: {test_suite_name}")
> >          except Exception as e:
> > -            self._logger.exception("An error occurred when searching for test suites.")
> > -            self._result.update_setup(Result.ERROR, e)
> > +            self._logger.exception(f"Test suite setup ERROR: {test_suite_name}")
> > +            test_suite_result.update_setup(Result.ERROR, e)
> >
> >          else:
> > -            for test_suite_class in test_suite_classes:
> > -                test_suite = test_suite_class(sut_node, tg_node, test_suite_config.test_cases)
> > -
> > -                test_suite_name = test_suite.__class__.__name__
> > -                test_suite_result = build_target_result.add_test_suite(test_suite_name)
> > -                try:
> > -                    self._logger.info(f"Starting test suite setup: {test_suite_name}")
> > -                    test_suite.set_up_suite()
> > -                    test_suite_result.update_setup(Result.PASS)
> > -                    self._logger.info(f"Test suite setup successful: {test_suite_name}")
> > -                except Exception as e:
> > -                    self._logger.exception(f"Test suite setup ERROR: {test_suite_name}")
> > -                    test_suite_result.update_setup(Result.ERROR, e)
> > -
> > -                else:
> > -                    self._execute_test_suite(execution.func, test_suite, test_suite_result)
> > -
> > -                finally:
> > -                    try:
> > -                        test_suite.tear_down_suite()
> > -                        sut_node.kill_cleanup_dpdk_apps()
> > -                        test_suite_result.update_teardown(Result.PASS)
> > -                    except Exception as e:
> > -                        self._logger.exception(f"Test suite teardown ERROR: {test_suite_name}")
> > -                        self._logger.warning(
> > -                            f"Test suite '{test_suite_name}' teardown failed, "
> > -                            f"the next test suite may be affected."
> > -                        )
> > -                        test_suite_result.update_setup(Result.ERROR, e)
> > -                    if len(test_suite_result.get_errors()) > 0 and test_suite.is_blocking:
> > -                        raise BlockingTestSuiteError(test_suite_name)
> > +            self._execute_test_suite(
> > +                test_suite,
> > +                test_suite_with_cases.test_cases,
> > +                test_suite_result,
> > +            )
> > +        finally:
> > +            try:
> > +                test_suite.tear_down_suite()
> > +                sut_node.kill_cleanup_dpdk_apps()
> > +                test_suite_result.update_teardown(Result.PASS)
> > +            except Exception as e:
> > +                self._logger.exception(f"Test suite teardown ERROR: {test_suite_name}")
> > +                self._logger.warning(
> > +                    f"Test suite '{test_suite_name}' teardown failed, "
> > +                    "the next test suite may be affected."
> > +                )
> > +                test_suite_result.update_setup(Result.ERROR, e)
> > +            if len(test_suite_result.get_errors()) > 0 and test_suite.is_blocking:
> > +                raise BlockingTestSuiteError(test_suite_name)
> >
> >      def _execute_test_suite(
> > -        self, func: bool, test_suite: TestSuite, test_suite_result: TestSuiteResult
> > +        self,
> > +        test_suite: TestSuite,
> > +        test_cases: Iterable[MethodType],
> > +        test_suite_result: TestSuiteResult,
> >      ) -> None:
> > -        """Execute all discovered test cases in `test_suite`.
> > +        """Execute all `test_cases` in `test_suite`.
> >
> >          If the :option:`--re-run` command line argument or the :envvar:`DTS_RERUN` environment
> >          variable is set, in case of a test case failure, the test case will be executed again
> >          until it passes or it fails that many times in addition of the first failure.
> >
> >          Args:
> > -            func: Whether to execute functional test cases.
> >              test_suite: The test suite object.
> > +            test_cases: The list of test case methods.
> >              test_suite_result: The test suite level result object associated
> >                  with the current test suite.
> >          """
> > -        if func:
> > -            for test_case_method in test_suite._get_functional_test_cases():
> > -                test_case_name = test_case_method.__name__
> > -                test_case_result = test_suite_result.add_test_case(test_case_name)
> > -                all_attempts = SETTINGS.re_run + 1
> > -                attempt_nr = 1
> > +        for test_case_method in test_cases:
> > +            test_case_name = test_case_method.__name__
> > +            test_case_result = test_suite_result.add_test_case(test_case_name)
> > +            all_attempts = SETTINGS.re_run + 1
> > +            attempt_nr = 1
> > +            self._run_test_case(test_suite, test_case_method, test_case_result)
> > +            while not test_case_result and attempt_nr < all_attempts:
> > +                attempt_nr += 1
> > +                self._logger.info(
> > +                    f"Re-running FAILED test case '{test_case_name}'. "
> > +                    f"Attempt number {attempt_nr} out of {all_attempts}."
> > +                )
> >                  self._run_test_case(test_suite, test_case_method, test_case_result)
> > -                while not test_case_result and attempt_nr < all_attempts:
> > -                    attempt_nr += 1
> > -                    self._logger.info(
> > -                        f"Re-running FAILED test case '{test_case_name}'. "
> > -                        f"Attempt number {attempt_nr} out of {all_attempts}."
> > -                    )
> > -                    self._run_test_case(test_suite, test_case_method, test_case_result)
> >
> >      def _run_test_case(
> >          self,
> > @@ -399,7 +599,7 @@ def _run_test_case(
> >          test_case_method: MethodType,
> >          test_case_result: TestCaseResult,
> >      ) -> None:
> > -        """Setup, execute and teardown a test case in `test_suite`.
> > +        """Setup, execute and teardown `test_case_method` from `test_suite`.
> >
> >          Record the result of the setup and the teardown and handle failures.
> >
> > @@ -424,7 +624,7 @@ def _run_test_case(
> >
> >          else:
> >              # run test case if setup was successful
> > -            self._execute_test_case(test_case_method, test_case_result)
> > +            self._execute_test_case(test_suite, test_case_method, test_case_result)
> >
> >          finally:
> >              try:
> > @@ -440,11 +640,15 @@ def _run_test_case(
> >                  test_case_result.update(Result.ERROR)
> >
> >      def _execute_test_case(
> > -        self, test_case_method: MethodType, test_case_result: TestCaseResult
> > +        self,
> > +        test_suite: TestSuite,
> > +        test_case_method: MethodType,
> > +        test_case_result: TestCaseResult,
> >      ) -> None:
> > -        """Execute one test case, record the result and handle failures.
> > +        """Execute `test_case_method` from `test_suite`, record the result and handle failures.
> >
> >          Args:
> > +            test_suite: The test suite object.
> >              test_case_method: The test case method.
> >              test_case_result: The test case level result object associated
> >                  with the current test case.
> > @@ -452,7 +656,7 @@ def _execute_test_case(
> >          test_case_name = test_case_method.__name__
> >          try:
> >              self._logger.info(f"Starting test case execution: {test_case_name}")
> > -            test_case_method()
> > +            test_case_method(test_suite)
> >              test_case_result.update(Result.PASS)
> >              self._logger.info(f"Test case execution PASSED: {test_case_name}")
> >
> > diff --git a/dts/framework/settings.py b/dts/framework/settings.py
> > index 609c8d0e62..2b8bfbe0ed 100644
> > --- a/dts/framework/settings.py
> > +++ b/dts/framework/settings.py
> > @@ -253,8 +253,7 @@ def _get_parser() -> argparse.ArgumentParser:
> >          "--test-cases",
> >          action=_env_arg("DTS_TESTCASES"),
> >          default="",
> > -        help="[DTS_TESTCASES] Comma-separated list of test cases to execute. "
> > -        "Unknown test cases will be silently ignored.",
> > +        help="[DTS_TESTCASES] Comma-separated list of test cases to execute.",
> >      )
> >
> >      parser.add_argument(
> > diff --git a/dts/framework/test_result.py b/dts/framework/test_result.py
> > index 4467749a9d..075195fd5b 100644
> > --- a/dts/framework/test_result.py
> > +++ b/dts/framework/test_result.py
> > @@ -25,7 +25,9 @@
> >
> >  import os.path
> >  from collections.abc import MutableSequence
> > +from dataclasses import dataclass
> >  from enum import Enum, auto
> > +from types import MethodType
> >
> >  from .config import (
> >      OS,
> > @@ -36,10 +38,42 @@
> >      CPUType,
> >      NodeConfiguration,
> >      NodeInfo,
> > +    TestSuiteConfig,
> >  )
> >  from .exception import DTSError, ErrorSeverity
> >  from .logger import DTSLOG
> >  from .settings import SETTINGS
> > +from .test_suite import TestSuite
> > +
> > +
> > +@dataclass(slots=True, frozen=True)
> > +class TestSuiteWithCases:
> > +    """A test suite class with test case methods.
> > +
> > +    An auxiliary class holding a test case class with test case methods. The intended use of this
> > +    class is to hold a subset of test cases (which could be all test cases) because we don't have
> > +    all the data to instantiate the class at the point of inspection. The knowledge of this subset
> > +    is needed in case an error occurs before the class is instantiated and we need to record
> > +    which test cases were blocked by the error.
> > +
> > +    Attributes:
> > +        test_suite_class: The test suite class.
> > +        test_cases: The test case methods.
> > +    """
> > +
> > +    test_suite_class: type[TestSuite]
> > +    test_cases: list[MethodType]
> > +
> > +    def create_config(self) -> TestSuiteConfig:
> > +        """Generate a :class:`TestSuiteConfig` from the stored test suite with test cases.
> > +
> > +        Returns:
> > +            The :class:`TestSuiteConfig` representation.
> > +        """
> > +        return TestSuiteConfig(
> > +            test_suite=self.test_suite_class.__name__,
> > +            test_cases=[test_case.__name__ for test_case in self.test_cases],
> > +        )
> >
> >
> >  class Result(Enum):
> > diff --git a/dts/framework/test_suite.py b/dts/framework/test_suite.py
> > index b02fd36147..f9fe88093e 100644
> > --- a/dts/framework/test_suite.py
> > +++ b/dts/framework/test_suite.py
> > @@ -11,25 +11,17 @@
> >      * Testbed (SUT, TG) configuration,
> >      * Packet sending and verification,
> >      * Test case verification.
> > -
> > -The module also defines a function, :func:`get_test_suites`,
> > -for gathering test suites from a Python module.
> >  """
> >
> > -import importlib
> > -import inspect
> > -import re
> >  from ipaddress import IPv4Interface, IPv6Interface, ip_interface
> > -from types import MethodType
> > -from typing import Any, ClassVar, Union
> > +from typing import ClassVar, Union
> >
> >  from scapy.layers.inet import IP  # type: ignore[import]
> >  from scapy.layers.l2 import Ether  # type: ignore[import]
> >  from scapy.packet import Packet, Padding  # type: ignore[import]
> >
> > -from .exception import ConfigurationError, TestCaseVerifyError
> > +from .exception import TestCaseVerifyError
> >  from .logger import DTSLOG, getLogger
> > -from .settings import SETTINGS
> >  from .testbed_model import Port, PortLink, SutNode, TGNode
> >  from .utils import get_packet_summaries
> >
> > @@ -37,7 +29,6 @@
> >  class TestSuite(object):
> >      """The base class with building blocks needed by most test cases.
> >
> > -        * Test case filtering and collection,
> >          * Test suite setup/cleanup methods to override,
> >          * Test case setup/cleanup methods to override,
> >          * Test case verification,
> > @@ -71,7 +62,6 @@ class TestSuite(object):
> >      #: will block the execution of all subsequent test suites in the current build target.
> >      is_blocking: ClassVar[bool] = False
> >      _logger: DTSLOG
> > -    _test_cases_to_run: list[str]
> >      _port_links: list[PortLink]
> >      _sut_port_ingress: Port
> >      _sut_port_egress: Port
> > @@ -86,24 +76,19 @@ def __init__(
> >          self,
> >          sut_node: SutNode,
> >          tg_node: TGNode,
> > -        test_cases: list[str],
> >      ):
> >          """Initialize the test suite testbed information and basic configuration.
> >
> > -        Process what test cases to run, find links between ports and set up
> > -        default IP addresses to be used when configuring them.
> > +        Find links between ports and set up default IP addresses to be used when
> > +        configuring them.
> >
> >          Args:
> >              sut_node: The SUT node where the test suite will run.
> >              tg_node: The TG node where the test suite will run.
> > -            test_cases: The list of test cases to execute.
> > -                If empty, all test cases will be executed.
> >          """
> >          self.sut_node = sut_node
> >          self.tg_node = tg_node
> >          self._logger = getLogger(self.__class__.__name__)
> > -        self._test_cases_to_run = test_cases
> > -        self._test_cases_to_run.extend(SETTINGS.test_cases)
> >          self._port_links = []
> >          self._process_links()
> >          self._sut_port_ingress, self._tg_port_egress = (
> > @@ -364,65 +349,3 @@ def _verify_l3_packet(self, received_packet: IP, expected_packet: IP) -> bool:
> >          if received_packet.src != expected_packet.src or received_packet.dst != expected_packet.dst:
> >              return False
> >          return True
> > -
> > -    def _get_functional_test_cases(self) -> list[MethodType]:
> > -        """Get all functional test cases defined in this TestSuite.
> > -
> > -        Returns:
> > -            The list of functional test cases of this TestSuite.
> > -        """
> > -        return self._get_test_cases(r"test_(?!perf_)")
> > -
> > -    def _get_test_cases(self, test_case_regex: str) -> list[MethodType]:
> > -        """Return a list of test cases matching test_case_regex.
> > -
> > -        Returns:
> > -            The list of test cases matching test_case_regex of this TestSuite.
> > -        """
> > -        self._logger.debug(f"Searching for test cases in {self.__class__.__name__}.")
> > -        filtered_test_cases = []
> > -        for test_case_name, test_case in inspect.getmembers(self, inspect.ismethod):
> > -            if self._should_be_executed(test_case_name, test_case_regex):
> > -                filtered_test_cases.append(test_case)
> > -        cases_str = ", ".join((x.__name__ for x in filtered_test_cases))
> > -        self._logger.debug(f"Found test cases '{cases_str}' in {self.__class__.__name__}.")
> > -        return filtered_test_cases
> > -
> > -    def _should_be_executed(self, test_case_name: str, test_case_regex: str) -> bool:
> > -        """Check whether the test case should be scheduled to be executed."""
> > -        match = bool(re.match(test_case_regex, test_case_name))
> > -        if self._test_cases_to_run:
> > -            return match and test_case_name in self._test_cases_to_run
> > -
> > -        return match
> > -
> > -
> > -def get_test_suites(testsuite_module_path: str) -> list[type[TestSuite]]:
> > -    r"""Find all :class:`TestSuite`\s in a Python module.
> > -
> > -    Args:
> > -        testsuite_module_path: The path to the Python module.
> > -
> > -    Returns:
> > -        The list of :class:`TestSuite`\s found within the Python module.
> > -
> > -    Raises:
> > -        ConfigurationError: The test suite module was not found.
> > -    """
> > -
> > -    def is_test_suite(object: Any) -> bool:
> > -        try:
> > -            if issubclass(object, TestSuite) and object is not TestSuite:
> > -                return True
> > -        except TypeError:
> > -            return False
> > -        return False
> > -
> > -    try:
> > -        testcase_module = importlib.import_module(testsuite_module_path)
> > -    except ModuleNotFoundError as e:
> > -        raise ConfigurationError(f"Test suite '{testsuite_module_path}' not found.") from e
> > -    return [
> > -        test_suite_class
> > -        for _, test_suite_class in inspect.getmembers(testcase_module, is_test_suite)
> > -    ]
> > diff --git a/dts/pyproject.toml b/dts/pyproject.toml
> > index 28bd970ae4..8eb92b4f11 100644
> > --- a/dts/pyproject.toml
> > +++ b/dts/pyproject.toml
> > @@ -51,6 +51,9 @@ linters = "mccabe,pycodestyle,pydocstyle,pyflakes"
> >  format = "pylint"
> >  max_line_length = 100
> >
> > +[tool.pylama.linter.pycodestyle]
> > +ignore = "E203,W503"
> > +
> >  [tool.pylama.linter.pydocstyle]
> >  convention = "google"
> >
> > diff --git a/dts/tests/TestSuite_smoke_tests.py b/dts/tests/TestSuite_smoke_tests.py
> > index 5e2bac14bd..7b2a0e97f8 100644
> > --- a/dts/tests/TestSuite_smoke_tests.py
> > +++ b/dts/tests/TestSuite_smoke_tests.py
> > @@ -21,7 +21,7 @@
> >  from framework.utils import REGEX_FOR_PCI_ADDRESS
> >
> >
> > -class SmokeTests(TestSuite):
> > +class TestSmokeTests(TestSuite):
> >      """DPDK and infrastructure smoke test suite.
> >
> >      The test cases validate the most basic DPDK functionality needed for all other test suites.
> > --
> > 2.34.1
> >

^ permalink raw reply	[flat|nested] 28+ messages in thread

* Re: [PATCH v2 6/7] dts: refactor logging configuration
  2024-02-14  7:49       ` Juraj Linkeš
@ 2024-02-14 16:51         ` Jeremy Spewock
  0 siblings, 0 replies; 28+ messages in thread
From: Jeremy Spewock @ 2024-02-14 16:51 UTC (permalink / raw)
  To: Juraj Linkeš
  Cc: thomas, Honnappa.Nagarahalli, probb, paul.szczepanek, Luca.Vizzarro, dev

On Wed, Feb 14, 2024 at 2:49 AM Juraj Linkeš <juraj.linkes@pantheon.tech> wrote:
>
> Hi Jeremy,
>
> Just a reminder, please strip the parts you're not commenting on.
>
<snip>
> > > +    def __init__(self, *args, **kwargs):
> > > +        """Extend the constructor with extra file handlers."""
> > > +        self._extra_file_handlers = []
> > > +        super().__init__(*args, **kwargs)
> > >
> > > -        One handler logs to the console, the other one to a file, with either a regular or verbose
> > > -        format.
> > > +    def makeRecord(self, *args, **kwargs):
> >
> > Is the return type annotation here skipped because of the `:meta private:`?
> >
>
> Basically. We're modifying a method defined elsewhere, but there's no
> harm in adding the return type.
>
<snip>
> > > +    logging.setLoggerClass(DTSLogger)
> > > +    if name:
> > > +        name = f"{dts_root_logger_name}.{name}"
> > > +    else:
> > > +        name = dts_root_logger_name
> > > +    logger = logging.getLogger(name)
> > > +    logging.setLoggerClass(logging.Logger)
> >
> > What's the benefit of setting the logger class back to logging.Logger
> > here? Is the idea basically that if someone wanted to use the logging
> > module we shouldn't implicitly make them use our DTSLogger?
> >
>
> Yes. We should actually set it to whatever was there before (it may
> not be logging.Logger), so I'll change that.+
>
>
<snip>
> > > @@ -137,6 +141,7 @@ def run(self):
> > >
> > >              # for all Execution sections
> > >              for execution in self._configuration.executions:
> > > +                self._logger.set_stage(DtsStage.execution)
> >
> > This ends up getting set twice in short succession which of course
> > doesn't functionally cause a problem, but I don't exactly see the
> > point of setting it twice.
>
> I'm not sure what you're referring to. The stage is only set once at
> the start of each execution (and more generally, at the start of each
> stage). It's also set in each finally block to properly mark the
> cleanup of that stage. There are two finally blocks in the execution
> stage where that could happen, but otherwise it should be set exactly
> once.

You're right, I misread where it was being set the second time in the
code when I was reading this and thought there was a way where it
could get set twice but it cannot. Apologies, it makes sense that you
weren't sure what I was referring to because what I was referring to
doesn't exist.

>
> >  We could either set it here or set it in
> > the _run_execution, but i think it makes more sense to set it in the
> > _run_execution method as that is the method where we are doing the
> > setup, here we are only initializing the nodes which is still in a
> > sense "pre execution."
>
> Init nodes was pre-execution before the patch. I've moved it to
> execution because of two reasons:
> 1. The nodes are actually defined per-execution. It just made more
> sense to move them to execution. Also, if an error happens while
> connecting to a node, we don't want to abort the whole DTS run, just
> the execution, as the next execution could be connecting to a
> different node.
> 2. We're not just doing node init here, we're also discovering which
> test cases to run. This is essential to do as soon as possible (before
> anything else happens in the execution, such as connecting the nodes)
> so that we can mark blocked test cases in case of an error. Test case
> discovery is definitely part of each execution and putting node init
> after that was a natural consequence. There's an argument for
> discovering test cases of all executions before actually running any
> of the executions. It's certainly doable, but the code doesn't look
> that good (when not modifying the original execution config (which we
> shouldn't do) - I've tried) and there's actually not much of a point
> to do it this way, the result is almost the same. Where it makes a
> difference is when there's an error in test suite configuration and
> later executions - the user would have to wait for the previous
> execution to fully run to discover the error).
>

These are very good points. I was still thinking of these
initializations as part of the pre-execution but I agree with you that
the things we are doing are actually part of the execution. Thank you
for elaborating!

> >
> >
<snip>

^ permalink raw reply	[flat|nested] 28+ messages in thread

* [PATCH v3 0/7] test case blocking and logging
  2023-12-20 10:33 [RFC PATCH v1 0/5] test case blocking and logging Juraj Linkeš
                   ` (6 preceding siblings ...)
  2024-02-06 14:57 ` [PATCH v2 0/7] " Juraj Linkeš
@ 2024-02-23  7:54 ` Juraj Linkeš
  2024-02-23  7:54   ` [PATCH v3 1/7] dts: convert dts.py methods to class Juraj Linkeš
                     ` (6 more replies)
  7 siblings, 7 replies; 28+ messages in thread
From: Juraj Linkeš @ 2024-02-23  7:54 UTC (permalink / raw)
  To: thomas, Honnappa.Nagarahalli, jspewock, probb, paul.szczepanek,
	Luca.Vizzarro
  Cc: dev, Juraj Linkeš

We currently don't record test case results that couldn't be executed
because of a previous failure, such as when a test suite setup failed,
resulting in no executed test cases.

In order to record the test cases that couldn't be executed, we must
know the lists of test suites and test cases ahead of the actual test
suite execution, as an error could occur before we even start executing
test suites.

In addition, the patch series contains two refactors and one
improvement.

The first refactor is closely related. The dts.py was renamed to
runner.py and given a clear purpose - running the test suites and all
other orchestration needed to run test suites. The logic for this was
not all in the original dts.py module and it was brought there. The
runner is also responsible for recording results, which is the blocked
test cases are recorded.

The other refactor, logging, is related to the first refactor. The
logging module was simplified while extending capabilities. Each test
suite logs into its own log file in addition to the main log file which
the runner must handle (as it knows when we start executing particular
test suites). The runner also handles the switching between execution
stages for the purposes of logging.

The one aforementioned improvement is in unifying how we specify test
cases in the conf.yaml file and in the environment variable/command line
argument.

v2:
Rebase and update of the whole patch. There are changes in all parts of
the code, mainly improving the design and logic.
Also added the last patch which improves test suite specification on the
cmdline.

v3:
Improved variables in _get_test_suite_class along with docstring.
Fixed smoke test suite not being added into the list of test suites to
be executed.

Juraj Linkeš (7):
  dts: convert dts.py methods to class
  dts: move test suite execution logic to DTSRunner
  dts: filter test suites in executions
  dts: reorganize test result
  dts: block all test cases when earlier setup fails
  dts: refactor logging configuration
  dts: improve test suite and case filtering

 doc/guides/tools/dts.rst                      |  14 +-
 dts/framework/config/__init__.py              |  36 +-
 dts/framework/config/conf_yaml_schema.json    |   2 +-
 dts/framework/dts.py                          | 338 ---------
 dts/framework/logger.py                       | 236 +++---
 dts/framework/remote_session/__init__.py      |   6 +-
 .../interactive_remote_session.py             |   6 +-
 .../remote_session/interactive_shell.py       |   6 +-
 .../remote_session/remote_session.py          |   8 +-
 dts/framework/runner.py                       | 706 ++++++++++++++++++
 dts/framework/settings.py                     | 188 +++--
 dts/framework/test_result.py                  | 565 ++++++++------
 dts/framework/test_suite.py                   | 239 +-----
 dts/framework/testbed_model/node.py           |  11 +-
 dts/framework/testbed_model/os_session.py     |   7 +-
 .../traffic_generator/traffic_generator.py    |   6 +-
 dts/main.py                                   |   9 +-
 dts/pyproject.toml                            |   3 +
 dts/tests/TestSuite_smoke_tests.py            |   2 +-
 19 files changed, 1360 insertions(+), 1028 deletions(-)
 delete mode 100644 dts/framework/dts.py
 create mode 100644 dts/framework/runner.py

-- 
2.34.1


^ permalink raw reply	[flat|nested] 28+ messages in thread

* [PATCH v3 1/7] dts: convert dts.py methods to class
  2024-02-23  7:54 ` [PATCH v3 0/7] test case blocking and logging Juraj Linkeš
@ 2024-02-23  7:54   ` Juraj Linkeš
  2024-02-23  7:54   ` [PATCH v3 2/7] dts: move test suite execution logic to DTSRunner Juraj Linkeš
                     ` (5 subsequent siblings)
  6 siblings, 0 replies; 28+ messages in thread
From: Juraj Linkeš @ 2024-02-23  7:54 UTC (permalink / raw)
  To: thomas, Honnappa.Nagarahalli, jspewock, probb, paul.szczepanek,
	Luca.Vizzarro
  Cc: dev, Juraj Linkeš

The dts.py module deviates from the rest of the code without a clear
reason. Converting it into a class and using better naming will improve
organization and code readability.

Signed-off-by: Juraj Linkeš <juraj.linkes@pantheon.tech>
---
 dts/framework/dts.py    | 338 ----------------------------------------
 dts/framework/runner.py | 333 +++++++++++++++++++++++++++++++++++++++
 dts/main.py             |   6 +-
 3 files changed, 337 insertions(+), 340 deletions(-)
 delete mode 100644 dts/framework/dts.py
 create mode 100644 dts/framework/runner.py

diff --git a/dts/framework/dts.py b/dts/framework/dts.py
deleted file mode 100644
index e16d4578a0..0000000000
--- a/dts/framework/dts.py
+++ /dev/null
@@ -1,338 +0,0 @@
-# SPDX-License-Identifier: BSD-3-Clause
-# Copyright(c) 2010-2019 Intel Corporation
-# Copyright(c) 2022-2023 PANTHEON.tech s.r.o.
-# Copyright(c) 2022-2023 University of New Hampshire
-
-r"""Test suite runner module.
-
-A DTS run is split into stages:
-
-    #. Execution stage,
-    #. Build target stage,
-    #. Test suite stage,
-    #. Test case stage.
-
-The module is responsible for running tests on testbeds defined in the test run configuration.
-Each setup or teardown of each stage is recorded in a :class:`~.test_result.DTSResult` or
-one of its subclasses. The test case results are also recorded.
-
-If an error occurs, the current stage is aborted, the error is recorded and the run continues in
-the next iteration of the same stage. The return code is the highest `severity` of all
-:class:`~.exception.DTSError`\s.
-
-Example:
-    An error occurs in a build target setup. The current build target is aborted and the run
-    continues with the next build target. If the errored build target was the last one in the given
-    execution, the next execution begins.
-
-Attributes:
-    dts_logger: The logger instance used in this module.
-    result: The top level result used in the module.
-"""
-
-import sys
-
-from .config import (
-    BuildTargetConfiguration,
-    ExecutionConfiguration,
-    TestSuiteConfig,
-    load_config,
-)
-from .exception import BlockingTestSuiteError
-from .logger import DTSLOG, getLogger
-from .test_result import BuildTargetResult, DTSResult, ExecutionResult, Result
-from .test_suite import get_test_suites
-from .testbed_model import SutNode, TGNode
-
-# dummy defaults to satisfy linters
-dts_logger: DTSLOG = None  # type: ignore[assignment]
-result: DTSResult = DTSResult(dts_logger)
-
-
-def run_all() -> None:
-    """Run all build targets in all executions from the test run configuration.
-
-    Before running test suites, executions and build targets are first set up.
-    The executions and build targets defined in the test run configuration are iterated over.
-    The executions define which tests to run and where to run them and build targets define
-    the DPDK build setup.
-
-    The tests suites are set up for each execution/build target tuple and each scheduled
-    test case within the test suite is set up, executed and torn down. After all test cases
-    have been executed, the test suite is torn down and the next build target will be tested.
-
-    All the nested steps look like this:
-
-        #. Execution setup
-
-            #. Build target setup
-
-                #. Test suite setup
-
-                    #. Test case setup
-                    #. Test case logic
-                    #. Test case teardown
-
-                #. Test suite teardown
-
-            #. Build target teardown
-
-        #. Execution teardown
-
-    The test cases are filtered according to the specification in the test run configuration and
-    the :option:`--test-cases` command line argument or
-    the :envvar:`DTS_TESTCASES` environment variable.
-    """
-    global dts_logger
-    global result
-
-    # create a regular DTS logger and create a new result with it
-    dts_logger = getLogger("DTSRunner")
-    result = DTSResult(dts_logger)
-
-    # check the python version of the server that run dts
-    _check_dts_python_version()
-
-    sut_nodes: dict[str, SutNode] = {}
-    tg_nodes: dict[str, TGNode] = {}
-    try:
-        # for all Execution sections
-        for execution in load_config().executions:
-            sut_node = sut_nodes.get(execution.system_under_test_node.name)
-            tg_node = tg_nodes.get(execution.traffic_generator_node.name)
-
-            try:
-                if not sut_node:
-                    sut_node = SutNode(execution.system_under_test_node)
-                    sut_nodes[sut_node.name] = sut_node
-                if not tg_node:
-                    tg_node = TGNode(execution.traffic_generator_node)
-                    tg_nodes[tg_node.name] = tg_node
-                result.update_setup(Result.PASS)
-            except Exception as e:
-                failed_node = execution.system_under_test_node.name
-                if sut_node:
-                    failed_node = execution.traffic_generator_node.name
-                dts_logger.exception(f"Creation of node {failed_node} failed.")
-                result.update_setup(Result.FAIL, e)
-
-            else:
-                _run_execution(sut_node, tg_node, execution, result)
-
-    except Exception as e:
-        dts_logger.exception("An unexpected error has occurred.")
-        result.add_error(e)
-        raise
-
-    finally:
-        try:
-            for node in (sut_nodes | tg_nodes).values():
-                node.close()
-            result.update_teardown(Result.PASS)
-        except Exception as e:
-            dts_logger.exception("Final cleanup of nodes failed.")
-            result.update_teardown(Result.ERROR, e)
-
-    # we need to put the sys.exit call outside the finally clause to make sure
-    # that unexpected exceptions will propagate
-    # in that case, the error that should be reported is the uncaught exception as
-    # that is a severe error originating from the framework
-    # at that point, we'll only have partial results which could be impacted by the
-    # error causing the uncaught exception, making them uninterpretable
-    _exit_dts()
-
-
-def _check_dts_python_version() -> None:
-    """Check the required Python version - v3.10."""
-
-    def RED(text: str) -> str:
-        return f"\u001B[31;1m{str(text)}\u001B[0m"
-
-    if sys.version_info.major < 3 or (sys.version_info.major == 3 and sys.version_info.minor < 10):
-        print(
-            RED(
-                (
-                    "WARNING: DTS execution node's python version is lower than"
-                    "python 3.10, is deprecated and will not work in future releases."
-                )
-            ),
-            file=sys.stderr,
-        )
-        print(RED("Please use Python >= 3.10 instead"), file=sys.stderr)
-
-
-def _run_execution(
-    sut_node: SutNode,
-    tg_node: TGNode,
-    execution: ExecutionConfiguration,
-    result: DTSResult,
-) -> None:
-    """Run the given execution.
-
-    This involves running the execution setup as well as running all build targets
-    in the given execution. After that, execution teardown is run.
-
-    Args:
-        sut_node: The execution's SUT node.
-        tg_node: The execution's TG node.
-        execution: An execution's test run configuration.
-        result: The top level result object.
-    """
-    dts_logger.info(f"Running execution with SUT '{execution.system_under_test_node.name}'.")
-    execution_result = result.add_execution(sut_node.config)
-    execution_result.add_sut_info(sut_node.node_info)
-
-    try:
-        sut_node.set_up_execution(execution)
-        execution_result.update_setup(Result.PASS)
-    except Exception as e:
-        dts_logger.exception("Execution setup failed.")
-        execution_result.update_setup(Result.FAIL, e)
-
-    else:
-        for build_target in execution.build_targets:
-            _run_build_target(sut_node, tg_node, build_target, execution, execution_result)
-
-    finally:
-        try:
-            sut_node.tear_down_execution()
-            execution_result.update_teardown(Result.PASS)
-        except Exception as e:
-            dts_logger.exception("Execution teardown failed.")
-            execution_result.update_teardown(Result.FAIL, e)
-
-
-def _run_build_target(
-    sut_node: SutNode,
-    tg_node: TGNode,
-    build_target: BuildTargetConfiguration,
-    execution: ExecutionConfiguration,
-    execution_result: ExecutionResult,
-) -> None:
-    """Run the given build target.
-
-    This involves running the build target setup as well as running all test suites
-    in the given execution the build target is defined in.
-    After that, build target teardown is run.
-
-    Args:
-        sut_node: The execution's SUT node.
-        tg_node: The execution's TG node.
-        build_target: A build target's test run configuration.
-        execution: The build target's execution's test run configuration.
-        execution_result: The execution level result object associated with the execution.
-    """
-    dts_logger.info(f"Running build target '{build_target.name}'.")
-    build_target_result = execution_result.add_build_target(build_target)
-
-    try:
-        sut_node.set_up_build_target(build_target)
-        result.dpdk_version = sut_node.dpdk_version
-        build_target_result.add_build_target_info(sut_node.get_build_target_info())
-        build_target_result.update_setup(Result.PASS)
-    except Exception as e:
-        dts_logger.exception("Build target setup failed.")
-        build_target_result.update_setup(Result.FAIL, e)
-
-    else:
-        _run_all_suites(sut_node, tg_node, execution, build_target_result)
-
-    finally:
-        try:
-            sut_node.tear_down_build_target()
-            build_target_result.update_teardown(Result.PASS)
-        except Exception as e:
-            dts_logger.exception("Build target teardown failed.")
-            build_target_result.update_teardown(Result.FAIL, e)
-
-
-def _run_all_suites(
-    sut_node: SutNode,
-    tg_node: TGNode,
-    execution: ExecutionConfiguration,
-    build_target_result: BuildTargetResult,
-) -> None:
-    """Run the execution's (possibly a subset) test suites using the current build target.
-
-    The function assumes the build target we're testing has already been built on the SUT node.
-    The current build target thus corresponds to the current DPDK build present on the SUT node.
-
-    If a blocking test suite (such as the smoke test suite) fails, the rest of the test suites
-    in the current build target won't be executed.
-
-    Args:
-        sut_node: The execution's SUT node.
-        tg_node: The execution's TG node.
-        execution: The execution's test run configuration associated with the current build target.
-        build_target_result: The build target level result object associated
-            with the current build target.
-    """
-    end_build_target = False
-    if not execution.skip_smoke_tests:
-        execution.test_suites[:0] = [TestSuiteConfig.from_dict("smoke_tests")]
-    for test_suite_config in execution.test_suites:
-        try:
-            _run_single_suite(sut_node, tg_node, execution, build_target_result, test_suite_config)
-        except BlockingTestSuiteError as e:
-            dts_logger.exception(
-                f"An error occurred within {test_suite_config.test_suite}. Skipping build target."
-            )
-            result.add_error(e)
-            end_build_target = True
-        # if a blocking test failed and we need to bail out of suite executions
-        if end_build_target:
-            break
-
-
-def _run_single_suite(
-    sut_node: SutNode,
-    tg_node: TGNode,
-    execution: ExecutionConfiguration,
-    build_target_result: BuildTargetResult,
-    test_suite_config: TestSuiteConfig,
-) -> None:
-    """Run all test suite in a single test suite module.
-
-    The function assumes the build target we're testing has already been built on the SUT node.
-    The current build target thus corresponds to the current DPDK build present on the SUT node.
-
-    Args:
-        sut_node: The execution's SUT node.
-        tg_node: The execution's TG node.
-        execution: The execution's test run configuration associated with the current build target.
-        build_target_result: The build target level result object associated
-            with the current build target.
-        test_suite_config: Test suite test run configuration specifying the test suite module
-            and possibly a subset of test cases of test suites in that module.
-
-    Raises:
-        BlockingTestSuiteError: If a blocking test suite fails.
-    """
-    try:
-        full_suite_path = f"tests.TestSuite_{test_suite_config.test_suite}"
-        test_suite_classes = get_test_suites(full_suite_path)
-        suites_str = ", ".join((x.__name__ for x in test_suite_classes))
-        dts_logger.debug(f"Found test suites '{suites_str}' in '{full_suite_path}'.")
-    except Exception as e:
-        dts_logger.exception("An error occurred when searching for test suites.")
-        result.update_setup(Result.ERROR, e)
-
-    else:
-        for test_suite_class in test_suite_classes:
-            test_suite = test_suite_class(
-                sut_node,
-                tg_node,
-                test_suite_config.test_cases,
-                execution.func,
-                build_target_result,
-            )
-            test_suite.run()
-
-
-def _exit_dts() -> None:
-    """Process all errors and exit with the proper exit code."""
-    result.process()
-
-    if dts_logger:
-        dts_logger.info("DTS execution has ended.")
-    sys.exit(result.get_return_code())
diff --git a/dts/framework/runner.py b/dts/framework/runner.py
new file mode 100644
index 0000000000..acc1c4d6db
--- /dev/null
+++ b/dts/framework/runner.py
@@ -0,0 +1,333 @@
+# SPDX-License-Identifier: BSD-3-Clause
+# Copyright(c) 2010-2019 Intel Corporation
+# Copyright(c) 2022-2023 PANTHEON.tech s.r.o.
+# Copyright(c) 2022-2023 University of New Hampshire
+
+"""Test suite runner module.
+
+The module is responsible for running DTS in a series of stages:
+
+    #. Execution stage,
+    #. Build target stage,
+    #. Test suite stage,
+    #. Test case stage.
+
+The execution and build target stages set up the environment before running test suites.
+The test suite stage sets up steps common to all test cases
+and the test case stage runs test cases individually.
+"""
+
+import logging
+import sys
+
+from .config import (
+    BuildTargetConfiguration,
+    ExecutionConfiguration,
+    TestSuiteConfig,
+    load_config,
+)
+from .exception import BlockingTestSuiteError
+from .logger import DTSLOG, getLogger
+from .test_result import BuildTargetResult, DTSResult, ExecutionResult, Result
+from .test_suite import get_test_suites
+from .testbed_model import SutNode, TGNode
+
+
+class DTSRunner:
+    r"""Test suite runner class.
+
+    The class is responsible for running tests on testbeds defined in the test run configuration.
+    Each setup or teardown of each stage is recorded in a :class:`~framework.test_result.DTSResult`
+    or one of its subclasses. The test case results are also recorded.
+
+    If an error occurs, the current stage is aborted, the error is recorded and the run continues in
+    the next iteration of the same stage. The return code is the highest `severity` of all
+    :class:`~.framework.exception.DTSError`\s.
+
+    Example:
+        An error occurs in a build target setup. The current build target is aborted and the run
+        continues with the next build target. If the errored build target was the last one in the
+        given execution, the next execution begins.
+    """
+
+    _logger: DTSLOG
+    _result: DTSResult
+
+    def __init__(self):
+        """Initialize the instance with logger and result."""
+        self._logger = getLogger("DTSRunner")
+        self._result = DTSResult(self._logger)
+
+    def run(self):
+        """Run all build targets in all executions from the test run configuration.
+
+        Before running test suites, executions and build targets are first set up.
+        The executions and build targets defined in the test run configuration are iterated over.
+        The executions define which tests to run and where to run them and build targets define
+        the DPDK build setup.
+
+        The tests suites are set up for each execution/build target tuple and each discovered
+        test case within the test suite is set up, executed and torn down. After all test cases
+        have been executed, the test suite is torn down and the next build target will be tested.
+
+        All the nested steps look like this:
+
+            #. Execution setup
+
+                #. Build target setup
+
+                    #. Test suite setup
+
+                        #. Test case setup
+                        #. Test case logic
+                        #. Test case teardown
+
+                    #. Test suite teardown
+
+                #. Build target teardown
+
+            #. Execution teardown
+
+        The test cases are filtered according to the specification in the test run configuration and
+        the :option:`--test-cases` command line argument or
+        the :envvar:`DTS_TESTCASES` environment variable.
+        """
+        sut_nodes: dict[str, SutNode] = {}
+        tg_nodes: dict[str, TGNode] = {}
+        try:
+            # check the python version of the server that runs dts
+            self._check_dts_python_version()
+
+            # for all Execution sections
+            for execution in load_config().executions:
+                sut_node = sut_nodes.get(execution.system_under_test_node.name)
+                tg_node = tg_nodes.get(execution.traffic_generator_node.name)
+
+                try:
+                    if not sut_node:
+                        sut_node = SutNode(execution.system_under_test_node)
+                        sut_nodes[sut_node.name] = sut_node
+                    if not tg_node:
+                        tg_node = TGNode(execution.traffic_generator_node)
+                        tg_nodes[tg_node.name] = tg_node
+                    self._result.update_setup(Result.PASS)
+                except Exception as e:
+                    failed_node = execution.system_under_test_node.name
+                    if sut_node:
+                        failed_node = execution.traffic_generator_node.name
+                    self._logger.exception(f"The Creation of node {failed_node} failed.")
+                    self._result.update_setup(Result.FAIL, e)
+
+                else:
+                    self._run_execution(sut_node, tg_node, execution)
+
+        except Exception as e:
+            self._logger.exception("An unexpected error has occurred.")
+            self._result.add_error(e)
+            raise
+
+        finally:
+            try:
+                for node in (sut_nodes | tg_nodes).values():
+                    node.close()
+                self._result.update_teardown(Result.PASS)
+            except Exception as e:
+                self._logger.exception("The final cleanup of nodes failed.")
+                self._result.update_teardown(Result.ERROR, e)
+
+        # we need to put the sys.exit call outside the finally clause to make sure
+        # that unexpected exceptions will propagate
+        # in that case, the error that should be reported is the uncaught exception as
+        # that is a severe error originating from the framework
+        # at that point, we'll only have partial results which could be impacted by the
+        # error causing the uncaught exception, making them uninterpretable
+        self._exit_dts()
+
+    def _check_dts_python_version(self) -> None:
+        """Check the required Python version - v3.10."""
+        if sys.version_info.major < 3 or (
+            sys.version_info.major == 3 and sys.version_info.minor < 10
+        ):
+            self._logger.warning(
+                "DTS execution node's python version is lower than Python 3.10, "
+                "is deprecated and will not work in future releases."
+            )
+            self._logger.warning("Please use Python >= 3.10 instead.")
+
+    def _run_execution(
+        self,
+        sut_node: SutNode,
+        tg_node: TGNode,
+        execution: ExecutionConfiguration,
+    ) -> None:
+        """Run the given execution.
+
+        This involves running the execution setup as well as running all build targets
+        in the given execution. After that, execution teardown is run.
+
+        Args:
+            sut_node: The execution's SUT node.
+            tg_node: The execution's TG node.
+            execution: An execution's test run configuration.
+        """
+        self._logger.info(f"Running execution with SUT '{execution.system_under_test_node.name}'.")
+        execution_result = self._result.add_execution(sut_node.config)
+        execution_result.add_sut_info(sut_node.node_info)
+
+        try:
+            sut_node.set_up_execution(execution)
+            execution_result.update_setup(Result.PASS)
+        except Exception as e:
+            self._logger.exception("Execution setup failed.")
+            execution_result.update_setup(Result.FAIL, e)
+
+        else:
+            for build_target in execution.build_targets:
+                self._run_build_target(sut_node, tg_node, build_target, execution, execution_result)
+
+        finally:
+            try:
+                sut_node.tear_down_execution()
+                execution_result.update_teardown(Result.PASS)
+            except Exception as e:
+                self._logger.exception("Execution teardown failed.")
+                execution_result.update_teardown(Result.FAIL, e)
+
+    def _run_build_target(
+        self,
+        sut_node: SutNode,
+        tg_node: TGNode,
+        build_target: BuildTargetConfiguration,
+        execution: ExecutionConfiguration,
+        execution_result: ExecutionResult,
+    ) -> None:
+        """Run the given build target.
+
+        This involves running the build target setup as well as running all test suites
+        of the build target's execution.
+        After that, build target teardown is run.
+
+        Args:
+            sut_node: The execution's sut node.
+            tg_node: The execution's tg node.
+            build_target: A build target's test run configuration.
+            execution: The build target's execution's test run configuration.
+            execution_result: The execution level result object associated with the execution.
+        """
+        self._logger.info(f"Running build target '{build_target.name}'.")
+        build_target_result = execution_result.add_build_target(build_target)
+
+        try:
+            sut_node.set_up_build_target(build_target)
+            self._result.dpdk_version = sut_node.dpdk_version
+            build_target_result.add_build_target_info(sut_node.get_build_target_info())
+            build_target_result.update_setup(Result.PASS)
+        except Exception as e:
+            self._logger.exception("Build target setup failed.")
+            build_target_result.update_setup(Result.FAIL, e)
+
+        else:
+            self._run_all_suites(sut_node, tg_node, execution, build_target_result)
+
+        finally:
+            try:
+                sut_node.tear_down_build_target()
+                build_target_result.update_teardown(Result.PASS)
+            except Exception as e:
+                self._logger.exception("Build target teardown failed.")
+                build_target_result.update_teardown(Result.FAIL, e)
+
+    def _run_all_suites(
+        self,
+        sut_node: SutNode,
+        tg_node: TGNode,
+        execution: ExecutionConfiguration,
+        build_target_result: BuildTargetResult,
+    ) -> None:
+        """Run the execution's (possibly a subset of) test suites using the current build target.
+
+        The method assumes the build target we're testing has already been built on the SUT node.
+        The current build target thus corresponds to the current DPDK build present on the SUT node.
+
+        Args:
+            sut_node: The execution's SUT node.
+            tg_node: The execution's TG node.
+            execution: The execution's test run configuration associated
+                with the current build target.
+            build_target_result: The build target level result object associated
+                with the current build target.
+        """
+        end_build_target = False
+        if not execution.skip_smoke_tests:
+            execution.test_suites[:0] = [TestSuiteConfig.from_dict("smoke_tests")]
+        for test_suite_config in execution.test_suites:
+            try:
+                self._run_single_suite(
+                    sut_node, tg_node, execution, build_target_result, test_suite_config
+                )
+            except BlockingTestSuiteError as e:
+                self._logger.exception(
+                    f"An error occurred within {test_suite_config.test_suite}. "
+                    "Skipping build target..."
+                )
+                self._result.add_error(e)
+                end_build_target = True
+            # if a blocking test failed and we need to bail out of suite executions
+            if end_build_target:
+                break
+
+    def _run_single_suite(
+        self,
+        sut_node: SutNode,
+        tg_node: TGNode,
+        execution: ExecutionConfiguration,
+        build_target_result: BuildTargetResult,
+        test_suite_config: TestSuiteConfig,
+    ) -> None:
+        """Run all test suites in a single test suite module.
+
+        The method assumes the build target we're testing has already been built on the SUT node.
+        The current build target thus corresponds to the current DPDK build present on the SUT node.
+
+        Args:
+            sut_node: The execution's SUT node.
+            tg_node: The execution's TG node.
+            execution: The execution's test run configuration associated
+                with the current build target.
+            build_target_result: The build target level result object associated
+                with the current build target.
+            test_suite_config: Test suite test run configuration specifying the test suite module
+                and possibly a subset of test cases of test suites in that module.
+
+        Raises:
+            BlockingTestSuiteError: If a blocking test suite fails.
+        """
+        try:
+            full_suite_path = f"tests.TestSuite_{test_suite_config.test_suite}"
+            test_suite_classes = get_test_suites(full_suite_path)
+            suites_str = ", ".join((x.__name__ for x in test_suite_classes))
+            self._logger.debug(f"Found test suites '{suites_str}' in '{full_suite_path}'.")
+        except Exception as e:
+            self._logger.exception("An error occurred when searching for test suites.")
+            self._result.update_setup(Result.ERROR, e)
+
+        else:
+            for test_suite_class in test_suite_classes:
+                test_suite = test_suite_class(
+                    sut_node,
+                    tg_node,
+                    test_suite_config.test_cases,
+                    execution.func,
+                    build_target_result,
+                )
+                test_suite.run()
+
+    def _exit_dts(self) -> None:
+        """Process all errors and exit with the proper exit code."""
+        self._result.process()
+
+        if self._logger:
+            self._logger.info("DTS execution has ended.")
+
+        logging.shutdown()
+        sys.exit(self._result.get_return_code())
diff --git a/dts/main.py b/dts/main.py
index f703615d11..1ffe8ff81f 100755
--- a/dts/main.py
+++ b/dts/main.py
@@ -21,9 +21,11 @@ def main() -> None:
     be modified before the settings module is imported anywhere else in the framework.
     """
     settings.SETTINGS = settings.get_settings()
-    from framework import dts
 
-    dts.run_all()
+    from framework.runner import DTSRunner
+
+    dts = DTSRunner()
+    dts.run()
 
 
 # Main program begins here
-- 
2.34.1


^ permalink raw reply	[flat|nested] 28+ messages in thread

* [PATCH v3 2/7] dts: move test suite execution logic to DTSRunner
  2024-02-23  7:54 ` [PATCH v3 0/7] test case blocking and logging Juraj Linkeš
  2024-02-23  7:54   ` [PATCH v3 1/7] dts: convert dts.py methods to class Juraj Linkeš
@ 2024-02-23  7:54   ` Juraj Linkeš
  2024-02-23  7:54   ` [PATCH v3 3/7] dts: filter test suites in executions Juraj Linkeš
                     ` (4 subsequent siblings)
  6 siblings, 0 replies; 28+ messages in thread
From: Juraj Linkeš @ 2024-02-23  7:54 UTC (permalink / raw)
  To: thomas, Honnappa.Nagarahalli, jspewock, probb, paul.szczepanek,
	Luca.Vizzarro
  Cc: dev, Juraj Linkeš

Move the code responsible for running the test suite from the
TestSuite class to the DTSRunner class. This restructuring decision
was made to consolidate and unify the related logic into a single unit.

Signed-off-by: Juraj Linkeš <juraj.linkes@pantheon.tech>
---
 dts/framework/runner.py     | 175 ++++++++++++++++++++++++++++++++----
 dts/framework/test_suite.py | 152 ++-----------------------------
 2 files changed, 169 insertions(+), 158 deletions(-)

diff --git a/dts/framework/runner.py b/dts/framework/runner.py
index acc1c4d6db..933685d638 100644
--- a/dts/framework/runner.py
+++ b/dts/framework/runner.py
@@ -19,6 +19,7 @@
 
 import logging
 import sys
+from types import MethodType
 
 from .config import (
     BuildTargetConfiguration,
@@ -26,10 +27,18 @@
     TestSuiteConfig,
     load_config,
 )
-from .exception import BlockingTestSuiteError
+from .exception import BlockingTestSuiteError, SSHTimeoutError, TestCaseVerifyError
 from .logger import DTSLOG, getLogger
-from .test_result import BuildTargetResult, DTSResult, ExecutionResult, Result
-from .test_suite import get_test_suites
+from .settings import SETTINGS
+from .test_result import (
+    BuildTargetResult,
+    DTSResult,
+    ExecutionResult,
+    Result,
+    TestCaseResult,
+    TestSuiteResult,
+)
+from .test_suite import TestSuite, get_test_suites
 from .testbed_model import SutNode, TGNode
 
 
@@ -227,7 +236,7 @@ def _run_build_target(
             build_target_result.update_setup(Result.FAIL, e)
 
         else:
-            self._run_all_suites(sut_node, tg_node, execution, build_target_result)
+            self._run_test_suites(sut_node, tg_node, execution, build_target_result)
 
         finally:
             try:
@@ -237,7 +246,7 @@ def _run_build_target(
                 self._logger.exception("Build target teardown failed.")
                 build_target_result.update_teardown(Result.FAIL, e)
 
-    def _run_all_suites(
+    def _run_test_suites(
         self,
         sut_node: SutNode,
         tg_node: TGNode,
@@ -249,6 +258,9 @@ def _run_all_suites(
         The method assumes the build target we're testing has already been built on the SUT node.
         The current build target thus corresponds to the current DPDK build present on the SUT node.
 
+        If a blocking test suite (such as the smoke test suite) fails, the rest of the test suites
+        in the current build target won't be executed.
+
         Args:
             sut_node: The execution's SUT node.
             tg_node: The execution's TG node.
@@ -262,7 +274,7 @@ def _run_all_suites(
             execution.test_suites[:0] = [TestSuiteConfig.from_dict("smoke_tests")]
         for test_suite_config in execution.test_suites:
             try:
-                self._run_single_suite(
+                self._run_test_suite_module(
                     sut_node, tg_node, execution, build_target_result, test_suite_config
                 )
             except BlockingTestSuiteError as e:
@@ -276,7 +288,7 @@ def _run_all_suites(
             if end_build_target:
                 break
 
-    def _run_single_suite(
+    def _run_test_suite_module(
         self,
         sut_node: SutNode,
         tg_node: TGNode,
@@ -284,11 +296,18 @@ def _run_single_suite(
         build_target_result: BuildTargetResult,
         test_suite_config: TestSuiteConfig,
     ) -> None:
-        """Run all test suites in a single test suite module.
+        """Set up, execute and tear down all test suites in a single test suite module.
 
         The method assumes the build target we're testing has already been built on the SUT node.
         The current build target thus corresponds to the current DPDK build present on the SUT node.
 
+        Test suite execution consists of running the discovered test cases.
+        A test case run consists of setup, execution and teardown of said test case.
+
+        Record the setup and the teardown and handle failures.
+
+        The test cases to execute are discovered when creating the :class:`TestSuite` object.
+
         Args:
             sut_node: The execution's SUT node.
             tg_node: The execution's TG node.
@@ -313,14 +332,140 @@ def _run_single_suite(
 
         else:
             for test_suite_class in test_suite_classes:
-                test_suite = test_suite_class(
-                    sut_node,
-                    tg_node,
-                    test_suite_config.test_cases,
-                    execution.func,
-                    build_target_result,
+                test_suite = test_suite_class(sut_node, tg_node, test_suite_config.test_cases)
+
+                test_suite_name = test_suite.__class__.__name__
+                test_suite_result = build_target_result.add_test_suite(test_suite_name)
+                try:
+                    self._logger.info(f"Starting test suite setup: {test_suite_name}")
+                    test_suite.set_up_suite()
+                    test_suite_result.update_setup(Result.PASS)
+                    self._logger.info(f"Test suite setup successful: {test_suite_name}")
+                except Exception as e:
+                    self._logger.exception(f"Test suite setup ERROR: {test_suite_name}")
+                    test_suite_result.update_setup(Result.ERROR, e)
+
+                else:
+                    self._execute_test_suite(execution.func, test_suite, test_suite_result)
+
+                finally:
+                    try:
+                        test_suite.tear_down_suite()
+                        sut_node.kill_cleanup_dpdk_apps()
+                        test_suite_result.update_teardown(Result.PASS)
+                    except Exception as e:
+                        self._logger.exception(f"Test suite teardown ERROR: {test_suite_name}")
+                        self._logger.warning(
+                            f"Test suite '{test_suite_name}' teardown failed, "
+                            f"the next test suite may be affected."
+                        )
+                        test_suite_result.update_setup(Result.ERROR, e)
+                    if len(test_suite_result.get_errors()) > 0 and test_suite.is_blocking:
+                        raise BlockingTestSuiteError(test_suite_name)
+
+    def _execute_test_suite(
+        self, func: bool, test_suite: TestSuite, test_suite_result: TestSuiteResult
+    ) -> None:
+        """Execute all discovered test cases in `test_suite`.
+
+        If the :option:`--re-run` command line argument or the :envvar:`DTS_RERUN` environment
+        variable is set, in case of a test case failure, the test case will be executed again
+        until it passes or it fails that many times in addition of the first failure.
+
+        Args:
+            func: Whether to execute functional test cases.
+            test_suite: The test suite object.
+            test_suite_result: The test suite level result object associated
+                with the current test suite.
+        """
+        if func:
+            for test_case_method in test_suite._get_functional_test_cases():
+                test_case_name = test_case_method.__name__
+                test_case_result = test_suite_result.add_test_case(test_case_name)
+                all_attempts = SETTINGS.re_run + 1
+                attempt_nr = 1
+                self._run_test_case(test_suite, test_case_method, test_case_result)
+                while not test_case_result and attempt_nr < all_attempts:
+                    attempt_nr += 1
+                    self._logger.info(
+                        f"Re-running FAILED test case '{test_case_name}'. "
+                        f"Attempt number {attempt_nr} out of {all_attempts}."
+                    )
+                    self._run_test_case(test_suite, test_case_method, test_case_result)
+
+    def _run_test_case(
+        self,
+        test_suite: TestSuite,
+        test_case_method: MethodType,
+        test_case_result: TestCaseResult,
+    ) -> None:
+        """Setup, execute and teardown a test case in `test_suite`.
+
+        Record the result of the setup and the teardown and handle failures.
+
+        Args:
+            test_suite: The test suite object.
+            test_case_method: The test case method.
+            test_case_result: The test case level result object associated
+                with the current test case.
+        """
+        test_case_name = test_case_method.__name__
+
+        try:
+            # run set_up function for each case
+            test_suite.set_up_test_case()
+            test_case_result.update_setup(Result.PASS)
+        except SSHTimeoutError as e:
+            self._logger.exception(f"Test case setup FAILED: {test_case_name}")
+            test_case_result.update_setup(Result.FAIL, e)
+        except Exception as e:
+            self._logger.exception(f"Test case setup ERROR: {test_case_name}")
+            test_case_result.update_setup(Result.ERROR, e)
+
+        else:
+            # run test case if setup was successful
+            self._execute_test_case(test_case_method, test_case_result)
+
+        finally:
+            try:
+                test_suite.tear_down_test_case()
+                test_case_result.update_teardown(Result.PASS)
+            except Exception as e:
+                self._logger.exception(f"Test case teardown ERROR: {test_case_name}")
+                self._logger.warning(
+                    f"Test case '{test_case_name}' teardown failed, "
+                    f"the next test case may be affected."
                 )
-                test_suite.run()
+                test_case_result.update_teardown(Result.ERROR, e)
+                test_case_result.update(Result.ERROR)
+
+    def _execute_test_case(
+        self, test_case_method: MethodType, test_case_result: TestCaseResult
+    ) -> None:
+        """Execute one test case, record the result and handle failures.
+
+        Args:
+            test_case_method: The test case method.
+            test_case_result: The test case level result object associated
+                with the current test case.
+        """
+        test_case_name = test_case_method.__name__
+        try:
+            self._logger.info(f"Starting test case execution: {test_case_name}")
+            test_case_method()
+            test_case_result.update(Result.PASS)
+            self._logger.info(f"Test case execution PASSED: {test_case_name}")
+
+        except TestCaseVerifyError as e:
+            self._logger.exception(f"Test case execution FAILED: {test_case_name}")
+            test_case_result.update(Result.FAIL, e)
+        except Exception as e:
+            self._logger.exception(f"Test case execution ERROR: {test_case_name}")
+            test_case_result.update(Result.ERROR, e)
+        except KeyboardInterrupt:
+            self._logger.error(f"Test case execution INTERRUPTED by user: {test_case_name}")
+            test_case_result.update(Result.SKIP)
+            raise KeyboardInterrupt("Stop DTS")
 
     def _exit_dts(self) -> None:
         """Process all errors and exit with the proper exit code."""
diff --git a/dts/framework/test_suite.py b/dts/framework/test_suite.py
index dfb391ffbd..b02fd36147 100644
--- a/dts/framework/test_suite.py
+++ b/dts/framework/test_suite.py
@@ -8,7 +8,6 @@
 must be extended by subclasses which add test cases. The :class:`TestSuite` contains the basics
 needed by subclasses:
 
-    * Test suite and test case execution flow,
     * Testbed (SUT, TG) configuration,
     * Packet sending and verification,
     * Test case verification.
@@ -28,27 +27,22 @@
 from scapy.layers.l2 import Ether  # type: ignore[import]
 from scapy.packet import Packet, Padding  # type: ignore[import]
 
-from .exception import (
-    BlockingTestSuiteError,
-    ConfigurationError,
-    SSHTimeoutError,
-    TestCaseVerifyError,
-)
+from .exception import ConfigurationError, TestCaseVerifyError
 from .logger import DTSLOG, getLogger
 from .settings import SETTINGS
-from .test_result import BuildTargetResult, Result, TestCaseResult, TestSuiteResult
 from .testbed_model import Port, PortLink, SutNode, TGNode
 from .utils import get_packet_summaries
 
 
 class TestSuite(object):
-    """The base class with methods for handling the basic flow of a test suite.
+    """The base class with building blocks needed by most test cases.
 
         * Test case filtering and collection,
-        * Test suite setup/cleanup,
-        * Test setup/cleanup,
-        * Test case execution,
-        * Error handling and results storage.
+        * Test suite setup/cleanup methods to override,
+        * Test case setup/cleanup methods to override,
+        * Test case verification,
+        * Testbed configuration,
+        * Traffic sending and verification.
 
     Test cases are implemented by subclasses. Test cases are all methods starting with ``test_``,
     further divided into performance test cases (starting with ``test_perf_``)
@@ -60,10 +54,6 @@ class TestSuite(object):
     The union of both lists will be used. Any unknown test cases from the latter lists
     will be silently ignored.
 
-    If the :option:`--re-run` command line argument or the :envvar:`DTS_RERUN` environment variable
-    is set, in case of a test case failure, the test case will be executed again until it passes
-    or it fails that many times in addition of the first failure.
-
     The methods named ``[set_up|tear_down]_[suite|test_case]`` should be overridden in subclasses
     if the appropriate test suite/test case fixtures are needed.
 
@@ -82,8 +72,6 @@ class TestSuite(object):
     is_blocking: ClassVar[bool] = False
     _logger: DTSLOG
     _test_cases_to_run: list[str]
-    _func: bool
-    _result: TestSuiteResult
     _port_links: list[PortLink]
     _sut_port_ingress: Port
     _sut_port_egress: Port
@@ -99,30 +87,23 @@ def __init__(
         sut_node: SutNode,
         tg_node: TGNode,
         test_cases: list[str],
-        func: bool,
-        build_target_result: BuildTargetResult,
     ):
         """Initialize the test suite testbed information and basic configuration.
 
-        Process what test cases to run, create the associated
-        :class:`~.test_result.TestSuiteResult`, find links between ports
-        and set up default IP addresses to be used when configuring them.
+        Process what test cases to run, find links between ports and set up
+        default IP addresses to be used when configuring them.
 
         Args:
             sut_node: The SUT node where the test suite will run.
             tg_node: The TG node where the test suite will run.
             test_cases: The list of test cases to execute.
                 If empty, all test cases will be executed.
-            func: Whether to run functional tests.
-            build_target_result: The build target result this test suite is run in.
         """
         self.sut_node = sut_node
         self.tg_node = tg_node
         self._logger = getLogger(self.__class__.__name__)
         self._test_cases_to_run = test_cases
         self._test_cases_to_run.extend(SETTINGS.test_cases)
-        self._func = func
-        self._result = build_target_result.add_test_suite(self.__class__.__name__)
         self._port_links = []
         self._process_links()
         self._sut_port_ingress, self._tg_port_egress = (
@@ -384,62 +365,6 @@ def _verify_l3_packet(self, received_packet: IP, expected_packet: IP) -> bool:
             return False
         return True
 
-    def run(self) -> None:
-        """Set up, execute and tear down the whole suite.
-
-        Test suite execution consists of running all test cases scheduled to be executed.
-        A test case run consists of setup, execution and teardown of said test case.
-
-        Record the setup and the teardown and handle failures.
-
-        The list of scheduled test cases is constructed when creating the :class:`TestSuite` object.
-        """
-        test_suite_name = self.__class__.__name__
-
-        try:
-            self._logger.info(f"Starting test suite setup: {test_suite_name}")
-            self.set_up_suite()
-            self._result.update_setup(Result.PASS)
-            self._logger.info(f"Test suite setup successful: {test_suite_name}")
-        except Exception as e:
-            self._logger.exception(f"Test suite setup ERROR: {test_suite_name}")
-            self._result.update_setup(Result.ERROR, e)
-
-        else:
-            self._execute_test_suite()
-
-        finally:
-            try:
-                self.tear_down_suite()
-                self.sut_node.kill_cleanup_dpdk_apps()
-                self._result.update_teardown(Result.PASS)
-            except Exception as e:
-                self._logger.exception(f"Test suite teardown ERROR: {test_suite_name}")
-                self._logger.warning(
-                    f"Test suite '{test_suite_name}' teardown failed, "
-                    f"the next test suite may be affected."
-                )
-                self._result.update_setup(Result.ERROR, e)
-            if len(self._result.get_errors()) > 0 and self.is_blocking:
-                raise BlockingTestSuiteError(test_suite_name)
-
-    def _execute_test_suite(self) -> None:
-        """Execute all test cases scheduled to be executed in this suite."""
-        if self._func:
-            for test_case_method in self._get_functional_test_cases():
-                test_case_name = test_case_method.__name__
-                test_case_result = self._result.add_test_case(test_case_name)
-                all_attempts = SETTINGS.re_run + 1
-                attempt_nr = 1
-                self._run_test_case(test_case_method, test_case_result)
-                while not test_case_result and attempt_nr < all_attempts:
-                    attempt_nr += 1
-                    self._logger.info(
-                        f"Re-running FAILED test case '{test_case_name}'. "
-                        f"Attempt number {attempt_nr} out of {all_attempts}."
-                    )
-                    self._run_test_case(test_case_method, test_case_result)
-
     def _get_functional_test_cases(self) -> list[MethodType]:
         """Get all functional test cases defined in this TestSuite.
 
@@ -471,65 +396,6 @@ def _should_be_executed(self, test_case_name: str, test_case_regex: str) -> bool
 
         return match
 
-    def _run_test_case(
-        self, test_case_method: MethodType, test_case_result: TestCaseResult
-    ) -> None:
-        """Setup, execute and teardown a test case in this suite.
-
-        Record the result of the setup and the teardown and handle failures.
-        """
-        test_case_name = test_case_method.__name__
-
-        try:
-            # run set_up function for each case
-            self.set_up_test_case()
-            test_case_result.update_setup(Result.PASS)
-        except SSHTimeoutError as e:
-            self._logger.exception(f"Test case setup FAILED: {test_case_name}")
-            test_case_result.update_setup(Result.FAIL, e)
-        except Exception as e:
-            self._logger.exception(f"Test case setup ERROR: {test_case_name}")
-            test_case_result.update_setup(Result.ERROR, e)
-
-        else:
-            # run test case if setup was successful
-            self._execute_test_case(test_case_method, test_case_result)
-
-        finally:
-            try:
-                self.tear_down_test_case()
-                test_case_result.update_teardown(Result.PASS)
-            except Exception as e:
-                self._logger.exception(f"Test case teardown ERROR: {test_case_name}")
-                self._logger.warning(
-                    f"Test case '{test_case_name}' teardown failed, "
-                    f"the next test case may be affected."
-                )
-                test_case_result.update_teardown(Result.ERROR, e)
-                test_case_result.update(Result.ERROR)
-
-    def _execute_test_case(
-        self, test_case_method: MethodType, test_case_result: TestCaseResult
-    ) -> None:
-        """Execute one test case, record the result and handle failures."""
-        test_case_name = test_case_method.__name__
-        try:
-            self._logger.info(f"Starting test case execution: {test_case_name}")
-            test_case_method()
-            test_case_result.update(Result.PASS)
-            self._logger.info(f"Test case execution PASSED: {test_case_name}")
-
-        except TestCaseVerifyError as e:
-            self._logger.exception(f"Test case execution FAILED: {test_case_name}")
-            test_case_result.update(Result.FAIL, e)
-        except Exception as e:
-            self._logger.exception(f"Test case execution ERROR: {test_case_name}")
-            test_case_result.update(Result.ERROR, e)
-        except KeyboardInterrupt:
-            self._logger.error(f"Test case execution INTERRUPTED by user: {test_case_name}")
-            test_case_result.update(Result.SKIP)
-            raise KeyboardInterrupt("Stop DTS")
-
 
 def get_test_suites(testsuite_module_path: str) -> list[type[TestSuite]]:
     r"""Find all :class:`TestSuite`\s in a Python module.
-- 
2.34.1


^ permalink raw reply	[flat|nested] 28+ messages in thread

* [PATCH v3 3/7] dts: filter test suites in executions
  2024-02-23  7:54 ` [PATCH v3 0/7] test case blocking and logging Juraj Linkeš
  2024-02-23  7:54   ` [PATCH v3 1/7] dts: convert dts.py methods to class Juraj Linkeš
  2024-02-23  7:54   ` [PATCH v3 2/7] dts: move test suite execution logic to DTSRunner Juraj Linkeš
@ 2024-02-23  7:54   ` Juraj Linkeš
  2024-02-23  7:54   ` [PATCH v3 4/7] dts: reorganize test result Juraj Linkeš
                     ` (3 subsequent siblings)
  6 siblings, 0 replies; 28+ messages in thread
From: Juraj Linkeš @ 2024-02-23  7:54 UTC (permalink / raw)
  To: thomas, Honnappa.Nagarahalli, jspewock, probb, paul.szczepanek,
	Luca.Vizzarro
  Cc: dev, Juraj Linkeš

We're currently filtering which test cases to run after some setup
steps, such as DPDK build, have already been taken. This prohibits us to
mark the test suites and cases that were supposed to be run as blocked
when an earlier setup fails, as that information is not available at
that time.

To remedy this, move the filtering to the beginning of each execution.
This is the first action taken in each execution and if we can't filter
the test cases, such as due to invalid inputs, we abort the whole
execution. No test suites nor cases will be marked as blocked as we
don't know which were supposed to be run.

On top of that, the filtering takes place in the TestSuite class, which
should only concern itself with test suite and test case logic, not the
processing behind the scenes. The logic has been moved to DTSRunner
which should do all the processing needed to run test suites.

The filtering itself introduces a few changes/assumptions which are more
sensible than before:
1. Assumption: There is just one TestSuite child class in each test
   suite module. This was an implicit assumption before as we couldn't
   specify the TestSuite classes in the test run configuration, just the
   modules. The name of the TestSuite child class starts with "Test" and
   then corresponds to the name of the module with CamelCase naming.
2. Unknown test cases specified both in the test run configuration and
   the environment variable/command line argument are no longer silently
   ignored. This is a quality of life improvement for users, as they
   could easily be not aware of the silent ignoration.

Also, a change in the code results in pycodestyle warning and error:
[E] E203 whitespace before ':'
[W] W503 line break before binary operator

These two are not PEP8 compliant, so they're disabled.

Signed-off-by: Juraj Linkeš <juraj.linkes@pantheon.tech>
---
 dts/framework/config/__init__.py           |  24 +-
 dts/framework/config/conf_yaml_schema.json |   2 +-
 dts/framework/runner.py                    | 432 +++++++++++++++------
 dts/framework/settings.py                  |   3 +-
 dts/framework/test_result.py               |  34 ++
 dts/framework/test_suite.py                |  85 +---
 dts/pyproject.toml                         |   3 +
 dts/tests/TestSuite_smoke_tests.py         |   2 +-
 8 files changed, 388 insertions(+), 197 deletions(-)

diff --git a/dts/framework/config/__init__.py b/dts/framework/config/__init__.py
index 62eded7f04..c6a93b3b89 100644
--- a/dts/framework/config/__init__.py
+++ b/dts/framework/config/__init__.py
@@ -36,7 +36,7 @@
 import json
 import os.path
 import pathlib
-from dataclasses import dataclass
+from dataclasses import dataclass, fields
 from enum import auto, unique
 from typing import Union
 
@@ -506,6 +506,28 @@ def from_dict(
             vdevs=vdevs,
         )
 
+    def copy_and_modify(self, **kwargs) -> "ExecutionConfiguration":
+        """Create a shallow copy with any of the fields modified.
+
+        The only new data are those passed to this method.
+        The rest are copied from the object's fields calling the method.
+
+        Args:
+            **kwargs: The names and types of keyword arguments are defined
+                by the fields of the :class:`ExecutionConfiguration` class.
+
+        Returns:
+            The copied and modified execution configuration.
+        """
+        new_config = {}
+        for field in fields(self):
+            if field.name in kwargs:
+                new_config[field.name] = kwargs[field.name]
+            else:
+                new_config[field.name] = getattr(self, field.name)
+
+        return ExecutionConfiguration(**new_config)
+
 
 @dataclass(slots=True, frozen=True)
 class Configuration:
diff --git a/dts/framework/config/conf_yaml_schema.json b/dts/framework/config/conf_yaml_schema.json
index 84e45fe3c2..051b079fe4 100644
--- a/dts/framework/config/conf_yaml_schema.json
+++ b/dts/framework/config/conf_yaml_schema.json
@@ -197,7 +197,7 @@
         },
         "cases": {
           "type": "array",
-          "description": "If specified, only this subset of test suite's test cases will be run. Unknown test cases will be silently ignored.",
+          "description": "If specified, only this subset of test suite's test cases will be run.",
           "items": {
             "type": "string"
           },
diff --git a/dts/framework/runner.py b/dts/framework/runner.py
index 933685d638..e8030365ac 100644
--- a/dts/framework/runner.py
+++ b/dts/framework/runner.py
@@ -17,17 +17,27 @@
 and the test case stage runs test cases individually.
 """
 
+import importlib
+import inspect
 import logging
+import re
 import sys
 from types import MethodType
+from typing import Iterable
 
 from .config import (
     BuildTargetConfiguration,
+    Configuration,
     ExecutionConfiguration,
     TestSuiteConfig,
     load_config,
 )
-from .exception import BlockingTestSuiteError, SSHTimeoutError, TestCaseVerifyError
+from .exception import (
+    BlockingTestSuiteError,
+    ConfigurationError,
+    SSHTimeoutError,
+    TestCaseVerifyError,
+)
 from .logger import DTSLOG, getLogger
 from .settings import SETTINGS
 from .test_result import (
@@ -37,8 +47,9 @@
     Result,
     TestCaseResult,
     TestSuiteResult,
+    TestSuiteWithCases,
 )
-from .test_suite import TestSuite, get_test_suites
+from .test_suite import TestSuite
 from .testbed_model import SutNode, TGNode
 
 
@@ -59,13 +70,23 @@ class DTSRunner:
         given execution, the next execution begins.
     """
 
+    _configuration: Configuration
     _logger: DTSLOG
     _result: DTSResult
+    _test_suite_class_prefix: str
+    _test_suite_module_prefix: str
+    _func_test_case_regex: str
+    _perf_test_case_regex: str
 
     def __init__(self):
-        """Initialize the instance with logger and result."""
+        """Initialize the instance with configuration, logger, result and string constants."""
+        self._configuration = load_config()
         self._logger = getLogger("DTSRunner")
         self._result = DTSResult(self._logger)
+        self._test_suite_class_prefix = "Test"
+        self._test_suite_module_prefix = "tests.TestSuite_"
+        self._func_test_case_regex = r"test_(?!perf_)"
+        self._perf_test_case_regex = r"test_perf_"
 
     def run(self):
         """Run all build targets in all executions from the test run configuration.
@@ -106,29 +127,32 @@ def run(self):
         try:
             # check the python version of the server that runs dts
             self._check_dts_python_version()
+            self._result.update_setup(Result.PASS)
 
             # for all Execution sections
-            for execution in load_config().executions:
-                sut_node = sut_nodes.get(execution.system_under_test_node.name)
-                tg_node = tg_nodes.get(execution.traffic_generator_node.name)
-
+            for execution in self._configuration.executions:
+                self._logger.info(
+                    f"Running execution with SUT '{execution.system_under_test_node.name}'."
+                )
+                execution_result = self._result.add_execution(execution.system_under_test_node)
+                # we don't want to modify the original config, so create a copy
+                execution_test_suites = list(execution.test_suites)
+                if not execution.skip_smoke_tests:
+                    execution_test_suites[:0] = [TestSuiteConfig.from_dict("smoke_tests")]
                 try:
-                    if not sut_node:
-                        sut_node = SutNode(execution.system_under_test_node)
-                        sut_nodes[sut_node.name] = sut_node
-                    if not tg_node:
-                        tg_node = TGNode(execution.traffic_generator_node)
-                        tg_nodes[tg_node.name] = tg_node
-                    self._result.update_setup(Result.PASS)
+                    test_suites_with_cases = self._get_test_suites_with_cases(
+                        execution_test_suites, execution.func, execution.perf
+                    )
                 except Exception as e:
-                    failed_node = execution.system_under_test_node.name
-                    if sut_node:
-                        failed_node = execution.traffic_generator_node.name
-                    self._logger.exception(f"The Creation of node {failed_node} failed.")
-                    self._result.update_setup(Result.FAIL, e)
+                    self._logger.exception(
+                        f"Invalid test suite configuration found: " f"{execution_test_suites}."
+                    )
+                    execution_result.update_setup(Result.FAIL, e)
 
                 else:
-                    self._run_execution(sut_node, tg_node, execution)
+                    self._connect_nodes_and_run_execution(
+                        sut_nodes, tg_nodes, execution, execution_result, test_suites_with_cases
+                    )
 
         except Exception as e:
             self._logger.exception("An unexpected error has occurred.")
@@ -163,11 +187,206 @@ def _check_dts_python_version(self) -> None:
             )
             self._logger.warning("Please use Python >= 3.10 instead.")
 
+    def _get_test_suites_with_cases(
+        self,
+        test_suite_configs: list[TestSuiteConfig],
+        func: bool,
+        perf: bool,
+    ) -> list[TestSuiteWithCases]:
+        """Test suites with test cases discovery.
+
+        The test suites with test cases defined in the user configuration are discovered
+        and stored for future use so that we don't import the modules twice and so that
+        the list of test suites with test cases is available for recording right away.
+
+        Args:
+            test_suite_configs: Test suite configurations.
+            func: Whether to include functional test cases in the final list.
+            perf: Whether to include performance test cases in the final list.
+
+        Returns:
+            The discovered test suites, each with test cases.
+        """
+        test_suites_with_cases = []
+
+        for test_suite_config in test_suite_configs:
+            test_suite_class = self._get_test_suite_class(test_suite_config.test_suite)
+            test_cases = []
+            func_test_cases, perf_test_cases = self._filter_test_cases(
+                test_suite_class, set(test_suite_config.test_cases + SETTINGS.test_cases)
+            )
+            if func:
+                test_cases.extend(func_test_cases)
+            if perf:
+                test_cases.extend(perf_test_cases)
+
+            test_suites_with_cases.append(
+                TestSuiteWithCases(test_suite_class=test_suite_class, test_cases=test_cases)
+            )
+
+        return test_suites_with_cases
+
+    def _get_test_suite_class(self, module_name: str) -> type[TestSuite]:
+        """Find the :class:`TestSuite` class in `module_name`.
+
+        The full module name is `module_name` prefixed with `self._test_suite_module_prefix`.
+        The module name is a standard filename with words separated with underscores.
+        Search the `module_name` for a :class:`TestSuite` class which starts
+        with `self._test_suite_class_prefix`, continuing with CamelCase `module_name`.
+        The first matching class is returned.
+
+        The CamelCase convention is not tested, only lowercase strings are compared.
+
+        Args:
+            module_name: The module name without prefix where to search for the test suite.
+
+        Returns:
+            The found test suite class.
+
+        Raises:
+            ConfigurationError: If the corresponding module is not found or
+                a valid :class:`TestSuite` is not found in the module.
+        """
+
+        def is_test_suite(object) -> bool:
+            """Check whether `object` is a :class:`TestSuite`.
+
+            The `object` is a subclass of :class:`TestSuite`, but not :class:`TestSuite` itself.
+
+            Args:
+                object: The object to be checked.
+
+            Returns:
+                :data:`True` if `object` is a subclass of `TestSuite`.
+            """
+            try:
+                if issubclass(object, TestSuite) and object is not TestSuite:
+                    return True
+            except TypeError:
+                return False
+            return False
+
+        testsuite_module_path = f"{self._test_suite_module_prefix}{module_name}"
+        try:
+            test_suite_module = importlib.import_module(testsuite_module_path)
+        except ModuleNotFoundError as e:
+            raise ConfigurationError(
+                f"Test suite module '{testsuite_module_path}' not found."
+            ) from e
+
+        lowercase_suite_to_find = (
+            f"{self._test_suite_class_prefix}{module_name.replace('_', '')}".lower()
+        )
+        for class_name, class_obj in inspect.getmembers(test_suite_module, is_test_suite):
+            if (
+                class_name.startswith(self._test_suite_class_prefix)
+                and lowercase_suite_to_find == class_name.lower()
+            ):
+                return class_obj
+        raise ConfigurationError(
+            f"Couldn't find any valid test suites in {test_suite_module.__name__}."
+        )
+
+    def _filter_test_cases(
+        self, test_suite_class: type[TestSuite], test_cases_to_run: set[str]
+    ) -> tuple[list[MethodType], list[MethodType]]:
+        """Filter `test_cases_to_run` from `test_suite_class`.
+
+        There are two rounds of filtering if `test_cases_to_run` is not empty.
+        The first filters `test_cases_to_run` from all methods of `test_suite_class`.
+        Then the methods are separated into functional and performance test cases.
+        If a method matches neither the functional nor performance name prefix, it's an error.
+
+        Args:
+            test_suite_class: The class of the test suite.
+            test_cases_to_run: Test case names to filter from `test_suite_class`.
+                If empty, return all matching test cases.
+
+        Returns:
+            A list of test case methods that should be executed.
+
+        Raises:
+            ConfigurationError: If a test case from `test_cases_to_run` is not found
+                or it doesn't match either the functional nor performance name prefix.
+        """
+        func_test_cases = []
+        perf_test_cases = []
+        name_method_tuples = inspect.getmembers(test_suite_class, inspect.isfunction)
+        if test_cases_to_run:
+            name_method_tuples = [
+                (name, method) for name, method in name_method_tuples if name in test_cases_to_run
+            ]
+            if len(name_method_tuples) < len(test_cases_to_run):
+                missing_test_cases = test_cases_to_run - {name for name, _ in name_method_tuples}
+                raise ConfigurationError(
+                    f"Test cases {missing_test_cases} not found among methods "
+                    f"of {test_suite_class.__name__}."
+                )
+
+        for test_case_name, test_case_method in name_method_tuples:
+            if re.match(self._func_test_case_regex, test_case_name):
+                func_test_cases.append(test_case_method)
+            elif re.match(self._perf_test_case_regex, test_case_name):
+                perf_test_cases.append(test_case_method)
+            elif test_cases_to_run:
+                raise ConfigurationError(
+                    f"Method '{test_case_name}' matches neither "
+                    f"a functional nor a performance test case name."
+                )
+
+        return func_test_cases, perf_test_cases
+
+    def _connect_nodes_and_run_execution(
+        self,
+        sut_nodes: dict[str, SutNode],
+        tg_nodes: dict[str, TGNode],
+        execution: ExecutionConfiguration,
+        execution_result: ExecutionResult,
+        test_suites_with_cases: Iterable[TestSuiteWithCases],
+    ) -> None:
+        """Connect nodes, then continue to run the given execution.
+
+        Connect the :class:`SutNode` and the :class:`TGNode` of this `execution`.
+        If either has already been connected, it's going to be in either `sut_nodes` or `tg_nodes`,
+        respectively.
+        If not, connect and add the node to the respective `sut_nodes` or `tg_nodes` :class:`dict`.
+
+        Args:
+            sut_nodes: A dictionary storing connected/to be connected SUT nodes.
+            tg_nodes: A dictionary storing connected/to be connected TG nodes.
+            execution: An execution's test run configuration.
+            execution_result: The execution's result.
+            test_suites_with_cases: The test suites with test cases to run.
+        """
+        sut_node = sut_nodes.get(execution.system_under_test_node.name)
+        tg_node = tg_nodes.get(execution.traffic_generator_node.name)
+
+        try:
+            if not sut_node:
+                sut_node = SutNode(execution.system_under_test_node)
+                sut_nodes[sut_node.name] = sut_node
+            if not tg_node:
+                tg_node = TGNode(execution.traffic_generator_node)
+                tg_nodes[tg_node.name] = tg_node
+        except Exception as e:
+            failed_node = execution.system_under_test_node.name
+            if sut_node:
+                failed_node = execution.traffic_generator_node.name
+            self._logger.exception(f"The Creation of node {failed_node} failed.")
+            execution_result.update_setup(Result.FAIL, e)
+
+        else:
+            self._run_execution(
+                sut_node, tg_node, execution, execution_result, test_suites_with_cases
+            )
+
     def _run_execution(
         self,
         sut_node: SutNode,
         tg_node: TGNode,
         execution: ExecutionConfiguration,
+        execution_result: ExecutionResult,
+        test_suites_with_cases: Iterable[TestSuiteWithCases],
     ) -> None:
         """Run the given execution.
 
@@ -178,11 +397,11 @@ def _run_execution(
             sut_node: The execution's SUT node.
             tg_node: The execution's TG node.
             execution: An execution's test run configuration.
+            execution_result: The execution's result.
+            test_suites_with_cases: The test suites with test cases to run.
         """
         self._logger.info(f"Running execution with SUT '{execution.system_under_test_node.name}'.")
-        execution_result = self._result.add_execution(sut_node.config)
         execution_result.add_sut_info(sut_node.node_info)
-
         try:
             sut_node.set_up_execution(execution)
             execution_result.update_setup(Result.PASS)
@@ -192,7 +411,10 @@ def _run_execution(
 
         else:
             for build_target in execution.build_targets:
-                self._run_build_target(sut_node, tg_node, build_target, execution, execution_result)
+                build_target_result = execution_result.add_build_target(build_target)
+                self._run_build_target(
+                    sut_node, tg_node, build_target, build_target_result, test_suites_with_cases
+                )
 
         finally:
             try:
@@ -207,8 +429,8 @@ def _run_build_target(
         sut_node: SutNode,
         tg_node: TGNode,
         build_target: BuildTargetConfiguration,
-        execution: ExecutionConfiguration,
-        execution_result: ExecutionResult,
+        build_target_result: BuildTargetResult,
+        test_suites_with_cases: Iterable[TestSuiteWithCases],
     ) -> None:
         """Run the given build target.
 
@@ -220,11 +442,11 @@ def _run_build_target(
             sut_node: The execution's sut node.
             tg_node: The execution's tg node.
             build_target: A build target's test run configuration.
-            execution: The build target's execution's test run configuration.
-            execution_result: The execution level result object associated with the execution.
+            build_target_result: The build target level result object associated
+                with the current build target.
+            test_suites_with_cases: The test suites with test cases to run.
         """
         self._logger.info(f"Running build target '{build_target.name}'.")
-        build_target_result = execution_result.add_build_target(build_target)
 
         try:
             sut_node.set_up_build_target(build_target)
@@ -236,7 +458,7 @@ def _run_build_target(
             build_target_result.update_setup(Result.FAIL, e)
 
         else:
-            self._run_test_suites(sut_node, tg_node, execution, build_target_result)
+            self._run_test_suites(sut_node, tg_node, build_target_result, test_suites_with_cases)
 
         finally:
             try:
@@ -250,10 +472,10 @@ def _run_test_suites(
         self,
         sut_node: SutNode,
         tg_node: TGNode,
-        execution: ExecutionConfiguration,
         build_target_result: BuildTargetResult,
+        test_suites_with_cases: Iterable[TestSuiteWithCases],
     ) -> None:
-        """Run the execution's (possibly a subset of) test suites using the current build target.
+        """Run `test_suites_with_cases` with the current build target.
 
         The method assumes the build target we're testing has already been built on the SUT node.
         The current build target thus corresponds to the current DPDK build present on the SUT node.
@@ -264,22 +486,20 @@ def _run_test_suites(
         Args:
             sut_node: The execution's SUT node.
             tg_node: The execution's TG node.
-            execution: The execution's test run configuration associated
-                with the current build target.
             build_target_result: The build target level result object associated
                 with the current build target.
+            test_suites_with_cases: The test suites with test cases to run.
         """
         end_build_target = False
-        if not execution.skip_smoke_tests:
-            execution.test_suites[:0] = [TestSuiteConfig.from_dict("smoke_tests")]
-        for test_suite_config in execution.test_suites:
+        for test_suite_with_cases in test_suites_with_cases:
+            test_suite_result = build_target_result.add_test_suite(
+                test_suite_with_cases.test_suite_class.__name__
+            )
             try:
-                self._run_test_suite_module(
-                    sut_node, tg_node, execution, build_target_result, test_suite_config
-                )
+                self._run_test_suite(sut_node, tg_node, test_suite_result, test_suite_with_cases)
             except BlockingTestSuiteError as e:
                 self._logger.exception(
-                    f"An error occurred within {test_suite_config.test_suite}. "
+                    f"An error occurred within {test_suite_with_cases.test_suite_class.__name__}. "
                     "Skipping build target..."
                 )
                 self._result.add_error(e)
@@ -288,15 +508,14 @@ def _run_test_suites(
             if end_build_target:
                 break
 
-    def _run_test_suite_module(
+    def _run_test_suite(
         self,
         sut_node: SutNode,
         tg_node: TGNode,
-        execution: ExecutionConfiguration,
-        build_target_result: BuildTargetResult,
-        test_suite_config: TestSuiteConfig,
+        test_suite_result: TestSuiteResult,
+        test_suite_with_cases: TestSuiteWithCases,
     ) -> None:
-        """Set up, execute and tear down all test suites in a single test suite module.
+        """Set up, execute and tear down `test_suite_with_cases`.
 
         The method assumes the build target we're testing has already been built on the SUT node.
         The current build target thus corresponds to the current DPDK build present on the SUT node.
@@ -306,92 +525,79 @@ def _run_test_suite_module(
 
         Record the setup and the teardown and handle failures.
 
-        The test cases to execute are discovered when creating the :class:`TestSuite` object.
-
         Args:
             sut_node: The execution's SUT node.
             tg_node: The execution's TG node.
-            execution: The execution's test run configuration associated
-                with the current build target.
-            build_target_result: The build target level result object associated
-                with the current build target.
-            test_suite_config: Test suite test run configuration specifying the test suite module
-                and possibly a subset of test cases of test suites in that module.
+            test_suite_result: The test suite level result object associated
+                with the current test suite.
+            test_suite_with_cases: The test suite with test cases to run.
 
         Raises:
             BlockingTestSuiteError: If a blocking test suite fails.
         """
+        test_suite_name = test_suite_with_cases.test_suite_class.__name__
+        test_suite = test_suite_with_cases.test_suite_class(sut_node, tg_node)
         try:
-            full_suite_path = f"tests.TestSuite_{test_suite_config.test_suite}"
-            test_suite_classes = get_test_suites(full_suite_path)
-            suites_str = ", ".join((x.__name__ for x in test_suite_classes))
-            self._logger.debug(f"Found test suites '{suites_str}' in '{full_suite_path}'.")
+            self._logger.info(f"Starting test suite setup: {test_suite_name}")
+            test_suite.set_up_suite()
+            test_suite_result.update_setup(Result.PASS)
+            self._logger.info(f"Test suite setup successful: {test_suite_name}")
         except Exception as e:
-            self._logger.exception("An error occurred when searching for test suites.")
-            self._result.update_setup(Result.ERROR, e)
+            self._logger.exception(f"Test suite setup ERROR: {test_suite_name}")
+            test_suite_result.update_setup(Result.ERROR, e)
 
         else:
-            for test_suite_class in test_suite_classes:
-                test_suite = test_suite_class(sut_node, tg_node, test_suite_config.test_cases)
-
-                test_suite_name = test_suite.__class__.__name__
-                test_suite_result = build_target_result.add_test_suite(test_suite_name)
-                try:
-                    self._logger.info(f"Starting test suite setup: {test_suite_name}")
-                    test_suite.set_up_suite()
-                    test_suite_result.update_setup(Result.PASS)
-                    self._logger.info(f"Test suite setup successful: {test_suite_name}")
-                except Exception as e:
-                    self._logger.exception(f"Test suite setup ERROR: {test_suite_name}")
-                    test_suite_result.update_setup(Result.ERROR, e)
-
-                else:
-                    self._execute_test_suite(execution.func, test_suite, test_suite_result)
-
-                finally:
-                    try:
-                        test_suite.tear_down_suite()
-                        sut_node.kill_cleanup_dpdk_apps()
-                        test_suite_result.update_teardown(Result.PASS)
-                    except Exception as e:
-                        self._logger.exception(f"Test suite teardown ERROR: {test_suite_name}")
-                        self._logger.warning(
-                            f"Test suite '{test_suite_name}' teardown failed, "
-                            f"the next test suite may be affected."
-                        )
-                        test_suite_result.update_setup(Result.ERROR, e)
-                    if len(test_suite_result.get_errors()) > 0 and test_suite.is_blocking:
-                        raise BlockingTestSuiteError(test_suite_name)
+            self._execute_test_suite(
+                test_suite,
+                test_suite_with_cases.test_cases,
+                test_suite_result,
+            )
+        finally:
+            try:
+                test_suite.tear_down_suite()
+                sut_node.kill_cleanup_dpdk_apps()
+                test_suite_result.update_teardown(Result.PASS)
+            except Exception as e:
+                self._logger.exception(f"Test suite teardown ERROR: {test_suite_name}")
+                self._logger.warning(
+                    f"Test suite '{test_suite_name}' teardown failed, "
+                    "the next test suite may be affected."
+                )
+                test_suite_result.update_setup(Result.ERROR, e)
+            if len(test_suite_result.get_errors()) > 0 and test_suite.is_blocking:
+                raise BlockingTestSuiteError(test_suite_name)
 
     def _execute_test_suite(
-        self, func: bool, test_suite: TestSuite, test_suite_result: TestSuiteResult
+        self,
+        test_suite: TestSuite,
+        test_cases: Iterable[MethodType],
+        test_suite_result: TestSuiteResult,
     ) -> None:
-        """Execute all discovered test cases in `test_suite`.
+        """Execute all `test_cases` in `test_suite`.
 
         If the :option:`--re-run` command line argument or the :envvar:`DTS_RERUN` environment
         variable is set, in case of a test case failure, the test case will be executed again
         until it passes or it fails that many times in addition of the first failure.
 
         Args:
-            func: Whether to execute functional test cases.
             test_suite: The test suite object.
+            test_cases: The list of test case methods.
             test_suite_result: The test suite level result object associated
                 with the current test suite.
         """
-        if func:
-            for test_case_method in test_suite._get_functional_test_cases():
-                test_case_name = test_case_method.__name__
-                test_case_result = test_suite_result.add_test_case(test_case_name)
-                all_attempts = SETTINGS.re_run + 1
-                attempt_nr = 1
+        for test_case_method in test_cases:
+            test_case_name = test_case_method.__name__
+            test_case_result = test_suite_result.add_test_case(test_case_name)
+            all_attempts = SETTINGS.re_run + 1
+            attempt_nr = 1
+            self._run_test_case(test_suite, test_case_method, test_case_result)
+            while not test_case_result and attempt_nr < all_attempts:
+                attempt_nr += 1
+                self._logger.info(
+                    f"Re-running FAILED test case '{test_case_name}'. "
+                    f"Attempt number {attempt_nr} out of {all_attempts}."
+                )
                 self._run_test_case(test_suite, test_case_method, test_case_result)
-                while not test_case_result and attempt_nr < all_attempts:
-                    attempt_nr += 1
-                    self._logger.info(
-                        f"Re-running FAILED test case '{test_case_name}'. "
-                        f"Attempt number {attempt_nr} out of {all_attempts}."
-                    )
-                    self._run_test_case(test_suite, test_case_method, test_case_result)
 
     def _run_test_case(
         self,
@@ -399,7 +605,7 @@ def _run_test_case(
         test_case_method: MethodType,
         test_case_result: TestCaseResult,
     ) -> None:
-        """Setup, execute and teardown a test case in `test_suite`.
+        """Setup, execute and teardown `test_case_method` from `test_suite`.
 
         Record the result of the setup and the teardown and handle failures.
 
@@ -424,7 +630,7 @@ def _run_test_case(
 
         else:
             # run test case if setup was successful
-            self._execute_test_case(test_case_method, test_case_result)
+            self._execute_test_case(test_suite, test_case_method, test_case_result)
 
         finally:
             try:
@@ -440,11 +646,15 @@ def _run_test_case(
                 test_case_result.update(Result.ERROR)
 
     def _execute_test_case(
-        self, test_case_method: MethodType, test_case_result: TestCaseResult
+        self,
+        test_suite: TestSuite,
+        test_case_method: MethodType,
+        test_case_result: TestCaseResult,
     ) -> None:
-        """Execute one test case, record the result and handle failures.
+        """Execute `test_case_method` from `test_suite`, record the result and handle failures.
 
         Args:
+            test_suite: The test suite object.
             test_case_method: The test case method.
             test_case_result: The test case level result object associated
                 with the current test case.
@@ -452,7 +662,7 @@ def _execute_test_case(
         test_case_name = test_case_method.__name__
         try:
             self._logger.info(f"Starting test case execution: {test_case_name}")
-            test_case_method()
+            test_case_method(test_suite)
             test_case_result.update(Result.PASS)
             self._logger.info(f"Test case execution PASSED: {test_case_name}")
 
diff --git a/dts/framework/settings.py b/dts/framework/settings.py
index 609c8d0e62..2b8bfbe0ed 100644
--- a/dts/framework/settings.py
+++ b/dts/framework/settings.py
@@ -253,8 +253,7 @@ def _get_parser() -> argparse.ArgumentParser:
         "--test-cases",
         action=_env_arg("DTS_TESTCASES"),
         default="",
-        help="[DTS_TESTCASES] Comma-separated list of test cases to execute. "
-        "Unknown test cases will be silently ignored.",
+        help="[DTS_TESTCASES] Comma-separated list of test cases to execute.",
     )
 
     parser.add_argument(
diff --git a/dts/framework/test_result.py b/dts/framework/test_result.py
index 4467749a9d..075195fd5b 100644
--- a/dts/framework/test_result.py
+++ b/dts/framework/test_result.py
@@ -25,7 +25,9 @@
 
 import os.path
 from collections.abc import MutableSequence
+from dataclasses import dataclass
 from enum import Enum, auto
+from types import MethodType
 
 from .config import (
     OS,
@@ -36,10 +38,42 @@
     CPUType,
     NodeConfiguration,
     NodeInfo,
+    TestSuiteConfig,
 )
 from .exception import DTSError, ErrorSeverity
 from .logger import DTSLOG
 from .settings import SETTINGS
+from .test_suite import TestSuite
+
+
+@dataclass(slots=True, frozen=True)
+class TestSuiteWithCases:
+    """A test suite class with test case methods.
+
+    An auxiliary class holding a test case class with test case methods. The intended use of this
+    class is to hold a subset of test cases (which could be all test cases) because we don't have
+    all the data to instantiate the class at the point of inspection. The knowledge of this subset
+    is needed in case an error occurs before the class is instantiated and we need to record
+    which test cases were blocked by the error.
+
+    Attributes:
+        test_suite_class: The test suite class.
+        test_cases: The test case methods.
+    """
+
+    test_suite_class: type[TestSuite]
+    test_cases: list[MethodType]
+
+    def create_config(self) -> TestSuiteConfig:
+        """Generate a :class:`TestSuiteConfig` from the stored test suite with test cases.
+
+        Returns:
+            The :class:`TestSuiteConfig` representation.
+        """
+        return TestSuiteConfig(
+            test_suite=self.test_suite_class.__name__,
+            test_cases=[test_case.__name__ for test_case in self.test_cases],
+        )
 
 
 class Result(Enum):
diff --git a/dts/framework/test_suite.py b/dts/framework/test_suite.py
index b02fd36147..f9fe88093e 100644
--- a/dts/framework/test_suite.py
+++ b/dts/framework/test_suite.py
@@ -11,25 +11,17 @@
     * Testbed (SUT, TG) configuration,
     * Packet sending and verification,
     * Test case verification.
-
-The module also defines a function, :func:`get_test_suites`,
-for gathering test suites from a Python module.
 """
 
-import importlib
-import inspect
-import re
 from ipaddress import IPv4Interface, IPv6Interface, ip_interface
-from types import MethodType
-from typing import Any, ClassVar, Union
+from typing import ClassVar, Union
 
 from scapy.layers.inet import IP  # type: ignore[import]
 from scapy.layers.l2 import Ether  # type: ignore[import]
 from scapy.packet import Packet, Padding  # type: ignore[import]
 
-from .exception import ConfigurationError, TestCaseVerifyError
+from .exception import TestCaseVerifyError
 from .logger import DTSLOG, getLogger
-from .settings import SETTINGS
 from .testbed_model import Port, PortLink, SutNode, TGNode
 from .utils import get_packet_summaries
 
@@ -37,7 +29,6 @@
 class TestSuite(object):
     """The base class with building blocks needed by most test cases.
 
-        * Test case filtering and collection,
         * Test suite setup/cleanup methods to override,
         * Test case setup/cleanup methods to override,
         * Test case verification,
@@ -71,7 +62,6 @@ class TestSuite(object):
     #: will block the execution of all subsequent test suites in the current build target.
     is_blocking: ClassVar[bool] = False
     _logger: DTSLOG
-    _test_cases_to_run: list[str]
     _port_links: list[PortLink]
     _sut_port_ingress: Port
     _sut_port_egress: Port
@@ -86,24 +76,19 @@ def __init__(
         self,
         sut_node: SutNode,
         tg_node: TGNode,
-        test_cases: list[str],
     ):
         """Initialize the test suite testbed information and basic configuration.
 
-        Process what test cases to run, find links between ports and set up
-        default IP addresses to be used when configuring them.
+        Find links between ports and set up default IP addresses to be used when
+        configuring them.
 
         Args:
             sut_node: The SUT node where the test suite will run.
             tg_node: The TG node where the test suite will run.
-            test_cases: The list of test cases to execute.
-                If empty, all test cases will be executed.
         """
         self.sut_node = sut_node
         self.tg_node = tg_node
         self._logger = getLogger(self.__class__.__name__)
-        self._test_cases_to_run = test_cases
-        self._test_cases_to_run.extend(SETTINGS.test_cases)
         self._port_links = []
         self._process_links()
         self._sut_port_ingress, self._tg_port_egress = (
@@ -364,65 +349,3 @@ def _verify_l3_packet(self, received_packet: IP, expected_packet: IP) -> bool:
         if received_packet.src != expected_packet.src or received_packet.dst != expected_packet.dst:
             return False
         return True
-
-    def _get_functional_test_cases(self) -> list[MethodType]:
-        """Get all functional test cases defined in this TestSuite.
-
-        Returns:
-            The list of functional test cases of this TestSuite.
-        """
-        return self._get_test_cases(r"test_(?!perf_)")
-
-    def _get_test_cases(self, test_case_regex: str) -> list[MethodType]:
-        """Return a list of test cases matching test_case_regex.
-
-        Returns:
-            The list of test cases matching test_case_regex of this TestSuite.
-        """
-        self._logger.debug(f"Searching for test cases in {self.__class__.__name__}.")
-        filtered_test_cases = []
-        for test_case_name, test_case in inspect.getmembers(self, inspect.ismethod):
-            if self._should_be_executed(test_case_name, test_case_regex):
-                filtered_test_cases.append(test_case)
-        cases_str = ", ".join((x.__name__ for x in filtered_test_cases))
-        self._logger.debug(f"Found test cases '{cases_str}' in {self.__class__.__name__}.")
-        return filtered_test_cases
-
-    def _should_be_executed(self, test_case_name: str, test_case_regex: str) -> bool:
-        """Check whether the test case should be scheduled to be executed."""
-        match = bool(re.match(test_case_regex, test_case_name))
-        if self._test_cases_to_run:
-            return match and test_case_name in self._test_cases_to_run
-
-        return match
-
-
-def get_test_suites(testsuite_module_path: str) -> list[type[TestSuite]]:
-    r"""Find all :class:`TestSuite`\s in a Python module.
-
-    Args:
-        testsuite_module_path: The path to the Python module.
-
-    Returns:
-        The list of :class:`TestSuite`\s found within the Python module.
-
-    Raises:
-        ConfigurationError: The test suite module was not found.
-    """
-
-    def is_test_suite(object: Any) -> bool:
-        try:
-            if issubclass(object, TestSuite) and object is not TestSuite:
-                return True
-        except TypeError:
-            return False
-        return False
-
-    try:
-        testcase_module = importlib.import_module(testsuite_module_path)
-    except ModuleNotFoundError as e:
-        raise ConfigurationError(f"Test suite '{testsuite_module_path}' not found.") from e
-    return [
-        test_suite_class
-        for _, test_suite_class in inspect.getmembers(testcase_module, is_test_suite)
-    ]
diff --git a/dts/pyproject.toml b/dts/pyproject.toml
index 28bd970ae4..8eb92b4f11 100644
--- a/dts/pyproject.toml
+++ b/dts/pyproject.toml
@@ -51,6 +51,9 @@ linters = "mccabe,pycodestyle,pydocstyle,pyflakes"
 format = "pylint"
 max_line_length = 100
 
+[tool.pylama.linter.pycodestyle]
+ignore = "E203,W503"
+
 [tool.pylama.linter.pydocstyle]
 convention = "google"
 
diff --git a/dts/tests/TestSuite_smoke_tests.py b/dts/tests/TestSuite_smoke_tests.py
index 5e2bac14bd..7b2a0e97f8 100644
--- a/dts/tests/TestSuite_smoke_tests.py
+++ b/dts/tests/TestSuite_smoke_tests.py
@@ -21,7 +21,7 @@
 from framework.utils import REGEX_FOR_PCI_ADDRESS
 
 
-class SmokeTests(TestSuite):
+class TestSmokeTests(TestSuite):
     """DPDK and infrastructure smoke test suite.
 
     The test cases validate the most basic DPDK functionality needed for all other test suites.
-- 
2.34.1


^ permalink raw reply	[flat|nested] 28+ messages in thread

* [PATCH v3 4/7] dts: reorganize test result
  2024-02-23  7:54 ` [PATCH v3 0/7] test case blocking and logging Juraj Linkeš
                     ` (2 preceding siblings ...)
  2024-02-23  7:54   ` [PATCH v3 3/7] dts: filter test suites in executions Juraj Linkeš
@ 2024-02-23  7:54   ` Juraj Linkeš
  2024-02-23  7:55   ` [PATCH v3 5/7] dts: block all test cases when earlier setup fails Juraj Linkeš
                     ` (2 subsequent siblings)
  6 siblings, 0 replies; 28+ messages in thread
From: Juraj Linkeš @ 2024-02-23  7:54 UTC (permalink / raw)
  To: thomas, Honnappa.Nagarahalli, jspewock, probb, paul.szczepanek,
	Luca.Vizzarro
  Cc: dev, Juraj Linkeš

The current order of Result classes in the test_suite.py module is
guided by the needs of type hints, which is not as intuitively readable
as ordering them by the occurrences in code. The order goes from the
topmost level to lowermost:
BaseResult
DTSResult
ExecutionResult
BuildTargetResult
TestSuiteResult
TestCaseResult

This is the same order as they're used in the runner module and they're
also used in the same order between themselves in the test_result
module.

Signed-off-by: Juraj Linkeš <juraj.linkes@pantheon.tech>
---
 dts/framework/test_result.py | 411 ++++++++++++++++++-----------------
 1 file changed, 206 insertions(+), 205 deletions(-)

diff --git a/dts/framework/test_result.py b/dts/framework/test_result.py
index 075195fd5b..abdbafab10 100644
--- a/dts/framework/test_result.py
+++ b/dts/framework/test_result.py
@@ -28,6 +28,7 @@
 from dataclasses import dataclass
 from enum import Enum, auto
 from types import MethodType
+from typing import Union
 
 from .config import (
     OS,
@@ -129,58 +130,6 @@ def __bool__(self) -> bool:
         return bool(self.result)
 
 
-class Statistics(dict):
-    """How many test cases ended in which result state along some other basic information.
-
-    Subclassing :class:`dict` provides a convenient way to format the data.
-
-    The data are stored in the following keys:
-
-    * **PASS RATE** (:class:`int`) -- The FAIL/PASS ratio of all test cases.
-    * **DPDK VERSION** (:class:`str`) -- The tested DPDK version.
-    """
-
-    def __init__(self, dpdk_version: str | None):
-        """Extend the constructor with keys in which the data are stored.
-
-        Args:
-            dpdk_version: The version of tested DPDK.
-        """
-        super(Statistics, self).__init__()
-        for result in Result:
-            self[result.name] = 0
-        self["PASS RATE"] = 0.0
-        self["DPDK VERSION"] = dpdk_version
-
-    def __iadd__(self, other: Result) -> "Statistics":
-        """Add a Result to the final count.
-
-        Example:
-            stats: Statistics = Statistics()  # empty Statistics
-            stats += Result.PASS  # add a Result to `stats`
-
-        Args:
-            other: The Result to add to this statistics object.
-
-        Returns:
-            The modified statistics object.
-        """
-        self[other.name] += 1
-        self["PASS RATE"] = (
-            float(self[Result.PASS.name]) * 100 / sum(self[result.name] for result in Result)
-        )
-        return self
-
-    def __str__(self) -> str:
-        """Each line contains the formatted key = value pair."""
-        stats_str = ""
-        for key, value in self.items():
-            stats_str += f"{key:<12} = {value}\n"
-            # according to docs, we should use \n when writing to text files
-            # on all platforms
-        return stats_str
-
-
 class BaseResult(object):
     """Common data and behavior of DTS results.
 
@@ -245,7 +194,7 @@ def get_errors(self) -> list[Exception]:
         """
         return self._get_setup_teardown_errors() + self._get_inner_errors()
 
-    def add_stats(self, statistics: Statistics) -> None:
+    def add_stats(self, statistics: "Statistics") -> None:
         """Collate stats from the whole result hierarchy.
 
         Args:
@@ -255,91 +204,149 @@ def add_stats(self, statistics: Statistics) -> None:
             inner_result.add_stats(statistics)
 
 
-class TestCaseResult(BaseResult, FixtureResult):
-    r"""The test case specific result.
+class DTSResult(BaseResult):
+    """Stores environment information and test results from a DTS run.
 
-    Stores the result of the actual test case. This is done by adding an extra superclass
-    in :class:`FixtureResult`. The setup and teardown results are :class:`FixtureResult`\s and
-    the class is itself a record of the test case.
+        * Execution level information, such as testbed and the test suite list,
+        * Build target level information, such as compiler, target OS and cpu,
+        * Test suite and test case results,
+        * All errors that are caught and recorded during DTS execution.
+
+    The information is stored hierarchically. This is the first level of the hierarchy
+    and as such is where the data form the whole hierarchy is collated or processed.
+
+    The internal list stores the results of all executions.
 
     Attributes:
-        test_case_name: The test case name.
+        dpdk_version: The DPDK version to record.
     """
 
-    test_case_name: str
+    dpdk_version: str | None
+    _logger: DTSLOG
+    _errors: list[Exception]
+    _return_code: ErrorSeverity
+    _stats_result: Union["Statistics", None]
+    _stats_filename: str
 
-    def __init__(self, test_case_name: str):
-        """Extend the constructor with `test_case_name`.
+    def __init__(self, logger: DTSLOG):
+        """Extend the constructor with top-level specifics.
 
         Args:
-            test_case_name: The test case's name.
+            logger: The logger instance the whole result will use.
         """
-        super(TestCaseResult, self).__init__()
-        self.test_case_name = test_case_name
+        super(DTSResult, self).__init__()
+        self.dpdk_version = None
+        self._logger = logger
+        self._errors = []
+        self._return_code = ErrorSeverity.NO_ERR
+        self._stats_result = None
+        self._stats_filename = os.path.join(SETTINGS.output_dir, "statistics.txt")
 
-    def update(self, result: Result, error: Exception | None = None) -> None:
-        """Update the test case result.
+    def add_execution(self, sut_node: NodeConfiguration) -> "ExecutionResult":
+        """Add and return the inner result (execution).
 
-        This updates the result of the test case itself and doesn't affect
-        the results of the setup and teardown steps in any way.
+        Args:
+            sut_node: The SUT node's test run configuration.
+
+        Returns:
+            The execution's result.
+        """
+        execution_result = ExecutionResult(sut_node)
+        self._inner_results.append(execution_result)
+        return execution_result
+
+    def add_error(self, error: Exception) -> None:
+        """Record an error that occurred outside any execution.
 
         Args:
-            result: The result of the test case.
-            error: The error that occurred in case of a failure.
+            error: The exception to record.
         """
-        self.result = result
-        self.error = error
+        self._errors.append(error)
 
-    def _get_inner_errors(self) -> list[Exception]:
-        if self.error:
-            return [self.error]
-        return []
+    def process(self) -> None:
+        """Process the data after a whole DTS run.
 
-    def add_stats(self, statistics: Statistics) -> None:
-        r"""Add the test case result to statistics.
+        The data is added to inner objects during runtime and this object is not updated
+        at that time. This requires us to process the inner data after it's all been gathered.
 
-        The base method goes through the hierarchy recursively and this method is here to stop
-        the recursion, as the :class:`TestCaseResult`\s are the leaves of the hierarchy tree.
+        The processing gathers all errors and the statistics of test case results.
+        """
+        self._errors += self.get_errors()
+        if self._errors and self._logger:
+            self._logger.debug("Summary of errors:")
+            for error in self._errors:
+                self._logger.debug(repr(error))
 
-        Args:
-            statistics: The :class:`Statistics` object where the stats will be added.
+        self._stats_result = Statistics(self.dpdk_version)
+        self.add_stats(self._stats_result)
+        with open(self._stats_filename, "w+") as stats_file:
+            stats_file.write(str(self._stats_result))
+
+    def get_return_code(self) -> int:
+        """Go through all stored Exceptions and return the final DTS error code.
+
+        Returns:
+            The highest error code found.
         """
-        statistics += self.result
+        for error in self._errors:
+            error_return_code = ErrorSeverity.GENERIC_ERR
+            if isinstance(error, DTSError):
+                error_return_code = error.severity
 
-    def __bool__(self) -> bool:
-        """The test case passed only if setup, teardown and the test case itself passed."""
-        return bool(self.setup_result) and bool(self.teardown_result) and bool(self.result)
+            if error_return_code > self._return_code:
+                self._return_code = error_return_code
 
+        return int(self._return_code)
 
-class TestSuiteResult(BaseResult):
-    """The test suite specific result.
 
-    The internal list stores the results of all test cases in a given test suite.
+class ExecutionResult(BaseResult):
+    """The execution specific result.
+
+    The internal list stores the results of all build targets in a given execution.
 
     Attributes:
-        suite_name: The test suite name.
+        sut_node: The SUT node used in the execution.
+        sut_os_name: The operating system of the SUT node.
+        sut_os_version: The operating system version of the SUT node.
+        sut_kernel_version: The operating system kernel version of the SUT node.
     """
 
-    suite_name: str
+    sut_node: NodeConfiguration
+    sut_os_name: str
+    sut_os_version: str
+    sut_kernel_version: str
 
-    def __init__(self, suite_name: str):
-        """Extend the constructor with `suite_name`.
+    def __init__(self, sut_node: NodeConfiguration):
+        """Extend the constructor with the `sut_node`'s config.
 
         Args:
-            suite_name: The test suite's name.
+            sut_node: The SUT node's test run configuration used in the execution.
         """
-        super(TestSuiteResult, self).__init__()
-        self.suite_name = suite_name
+        super(ExecutionResult, self).__init__()
+        self.sut_node = sut_node
 
-    def add_test_case(self, test_case_name: str) -> TestCaseResult:
-        """Add and return the inner result (test case).
+    def add_build_target(self, build_target: BuildTargetConfiguration) -> "BuildTargetResult":
+        """Add and return the inner result (build target).
+
+        Args:
+            build_target: The build target's test run configuration.
 
         Returns:
-            The test case's result.
+            The build target's result.
         """
-        test_case_result = TestCaseResult(test_case_name)
-        self._inner_results.append(test_case_result)
-        return test_case_result
+        build_target_result = BuildTargetResult(build_target)
+        self._inner_results.append(build_target_result)
+        return build_target_result
+
+    def add_sut_info(self, sut_info: NodeInfo) -> None:
+        """Add SUT information gathered at runtime.
+
+        Args:
+            sut_info: The additional SUT node information.
+        """
+        self.sut_os_name = sut_info.os_name
+        self.sut_os_version = sut_info.os_version
+        self.sut_kernel_version = sut_info.kernel_version
 
 
 class BuildTargetResult(BaseResult):
@@ -386,7 +393,7 @@ def add_build_target_info(self, versions: BuildTargetInfo) -> None:
         self.compiler_version = versions.compiler_version
         self.dpdk_version = versions.dpdk_version
 
-    def add_test_suite(self, test_suite_name: str) -> TestSuiteResult:
+    def add_test_suite(self, test_suite_name: str) -> "TestSuiteResult":
         """Add and return the inner result (test suite).
 
         Returns:
@@ -397,146 +404,140 @@ def add_test_suite(self, test_suite_name: str) -> TestSuiteResult:
         return test_suite_result
 
 
-class ExecutionResult(BaseResult):
-    """The execution specific result.
+class TestSuiteResult(BaseResult):
+    """The test suite specific result.
 
-    The internal list stores the results of all build targets in a given execution.
+    The internal list stores the results of all test cases in a given test suite.
 
     Attributes:
-        sut_node: The SUT node used in the execution.
-        sut_os_name: The operating system of the SUT node.
-        sut_os_version: The operating system version of the SUT node.
-        sut_kernel_version: The operating system kernel version of the SUT node.
+        suite_name: The test suite name.
     """
 
-    sut_node: NodeConfiguration
-    sut_os_name: str
-    sut_os_version: str
-    sut_kernel_version: str
+    suite_name: str
 
-    def __init__(self, sut_node: NodeConfiguration):
-        """Extend the constructor with the `sut_node`'s config.
+    def __init__(self, suite_name: str):
+        """Extend the constructor with `suite_name`.
 
         Args:
-            sut_node: The SUT node's test run configuration used in the execution.
+            suite_name: The test suite's name.
         """
-        super(ExecutionResult, self).__init__()
-        self.sut_node = sut_node
-
-    def add_build_target(self, build_target: BuildTargetConfiguration) -> BuildTargetResult:
-        """Add and return the inner result (build target).
+        super(TestSuiteResult, self).__init__()
+        self.suite_name = suite_name
 
-        Args:
-            build_target: The build target's test run configuration.
+    def add_test_case(self, test_case_name: str) -> "TestCaseResult":
+        """Add and return the inner result (test case).
 
         Returns:
-            The build target's result.
-        """
-        build_target_result = BuildTargetResult(build_target)
-        self._inner_results.append(build_target_result)
-        return build_target_result
-
-    def add_sut_info(self, sut_info: NodeInfo) -> None:
-        """Add SUT information gathered at runtime.
-
-        Args:
-            sut_info: The additional SUT node information.
+            The test case's result.
         """
-        self.sut_os_name = sut_info.os_name
-        self.sut_os_version = sut_info.os_version
-        self.sut_kernel_version = sut_info.kernel_version
+        test_case_result = TestCaseResult(test_case_name)
+        self._inner_results.append(test_case_result)
+        return test_case_result
 
 
-class DTSResult(BaseResult):
-    """Stores environment information and test results from a DTS run.
-
-        * Execution level information, such as testbed and the test suite list,
-        * Build target level information, such as compiler, target OS and cpu,
-        * Test suite and test case results,
-        * All errors that are caught and recorded during DTS execution.
-
-    The information is stored hierarchically. This is the first level of the hierarchy
-    and as such is where the data form the whole hierarchy is collated or processed.
+class TestCaseResult(BaseResult, FixtureResult):
+    r"""The test case specific result.
 
-    The internal list stores the results of all executions.
+    Stores the result of the actual test case. This is done by adding an extra superclass
+    in :class:`FixtureResult`. The setup and teardown results are :class:`FixtureResult`\s and
+    the class is itself a record of the test case.
 
     Attributes:
-        dpdk_version: The DPDK version to record.
+        test_case_name: The test case name.
     """
 
-    dpdk_version: str | None
-    _logger: DTSLOG
-    _errors: list[Exception]
-    _return_code: ErrorSeverity
-    _stats_result: Statistics | None
-    _stats_filename: str
+    test_case_name: str
 
-    def __init__(self, logger: DTSLOG):
-        """Extend the constructor with top-level specifics.
+    def __init__(self, test_case_name: str):
+        """Extend the constructor with `test_case_name`.
 
         Args:
-            logger: The logger instance the whole result will use.
+            test_case_name: The test case's name.
         """
-        super(DTSResult, self).__init__()
-        self.dpdk_version = None
-        self._logger = logger
-        self._errors = []
-        self._return_code = ErrorSeverity.NO_ERR
-        self._stats_result = None
-        self._stats_filename = os.path.join(SETTINGS.output_dir, "statistics.txt")
+        super(TestCaseResult, self).__init__()
+        self.test_case_name = test_case_name
 
-    def add_execution(self, sut_node: NodeConfiguration) -> ExecutionResult:
-        """Add and return the inner result (execution).
+    def update(self, result: Result, error: Exception | None = None) -> None:
+        """Update the test case result.
 
-        Args:
-            sut_node: The SUT node's test run configuration.
+        This updates the result of the test case itself and doesn't affect
+        the results of the setup and teardown steps in any way.
 
-        Returns:
-            The execution's result.
+        Args:
+            result: The result of the test case.
+            error: The error that occurred in case of a failure.
         """
-        execution_result = ExecutionResult(sut_node)
-        self._inner_results.append(execution_result)
-        return execution_result
+        self.result = result
+        self.error = error
 
-    def add_error(self, error: Exception) -> None:
-        """Record an error that occurred outside any execution.
+    def _get_inner_errors(self) -> list[Exception]:
+        if self.error:
+            return [self.error]
+        return []
+
+    def add_stats(self, statistics: "Statistics") -> None:
+        r"""Add the test case result to statistics.
+
+        The base method goes through the hierarchy recursively and this method is here to stop
+        the recursion, as the :class:`TestCaseResult`\s are the leaves of the hierarchy tree.
 
         Args:
-            error: The exception to record.
+            statistics: The :class:`Statistics` object where the stats will be added.
         """
-        self._errors.append(error)
+        statistics += self.result
 
-    def process(self) -> None:
-        """Process the data after a whole DTS run.
+    def __bool__(self) -> bool:
+        """The test case passed only if setup, teardown and the test case itself passed."""
+        return bool(self.setup_result) and bool(self.teardown_result) and bool(self.result)
 
-        The data is added to inner objects during runtime and this object is not updated
-        at that time. This requires us to process the inner data after it's all been gathered.
 
-        The processing gathers all errors and the statistics of test case results.
+class Statistics(dict):
+    """How many test cases ended in which result state along some other basic information.
+
+    Subclassing :class:`dict` provides a convenient way to format the data.
+
+    The data are stored in the following keys:
+
+    * **PASS RATE** (:class:`int`) -- The FAIL/PASS ratio of all test cases.
+    * **DPDK VERSION** (:class:`str`) -- The tested DPDK version.
+    """
+
+    def __init__(self, dpdk_version: str | None):
+        """Extend the constructor with keys in which the data are stored.
+
+        Args:
+            dpdk_version: The version of tested DPDK.
         """
-        self._errors += self.get_errors()
-        if self._errors and self._logger:
-            self._logger.debug("Summary of errors:")
-            for error in self._errors:
-                self._logger.debug(repr(error))
+        super(Statistics, self).__init__()
+        for result in Result:
+            self[result.name] = 0
+        self["PASS RATE"] = 0.0
+        self["DPDK VERSION"] = dpdk_version
 
-        self._stats_result = Statistics(self.dpdk_version)
-        self.add_stats(self._stats_result)
-        with open(self._stats_filename, "w+") as stats_file:
-            stats_file.write(str(self._stats_result))
+    def __iadd__(self, other: Result) -> "Statistics":
+        """Add a Result to the final count.
 
-    def get_return_code(self) -> int:
-        """Go through all stored Exceptions and return the final DTS error code.
+        Example:
+            stats: Statistics = Statistics()  # empty Statistics
+            stats += Result.PASS  # add a Result to `stats`
+
+        Args:
+            other: The Result to add to this statistics object.
 
         Returns:
-            The highest error code found.
+            The modified statistics object.
         """
-        for error in self._errors:
-            error_return_code = ErrorSeverity.GENERIC_ERR
-            if isinstance(error, DTSError):
-                error_return_code = error.severity
-
-            if error_return_code > self._return_code:
-                self._return_code = error_return_code
+        self[other.name] += 1
+        self["PASS RATE"] = (
+            float(self[Result.PASS.name]) * 100 / sum(self[result.name] for result in Result)
+        )
+        return self
 
-        return int(self._return_code)
+    def __str__(self) -> str:
+        """Each line contains the formatted key = value pair."""
+        stats_str = ""
+        for key, value in self.items():
+            stats_str += f"{key:<12} = {value}\n"
+            # according to docs, we should use \n when writing to text files
+            # on all platforms
+        return stats_str
-- 
2.34.1


^ permalink raw reply	[flat|nested] 28+ messages in thread

* [PATCH v3 5/7] dts: block all test cases when earlier setup fails
  2024-02-23  7:54 ` [PATCH v3 0/7] test case blocking and logging Juraj Linkeš
                     ` (3 preceding siblings ...)
  2024-02-23  7:54   ` [PATCH v3 4/7] dts: reorganize test result Juraj Linkeš
@ 2024-02-23  7:55   ` Juraj Linkeš
  2024-02-23  7:55   ` [PATCH v3 6/7] dts: refactor logging configuration Juraj Linkeš
  2024-02-23  7:55   ` [PATCH v3 7/7] dts: improve test suite and case filtering Juraj Linkeš
  6 siblings, 0 replies; 28+ messages in thread
From: Juraj Linkeš @ 2024-02-23  7:55 UTC (permalink / raw)
  To: thomas, Honnappa.Nagarahalli, jspewock, probb, paul.szczepanek,
	Luca.Vizzarro
  Cc: dev, Juraj Linkeš

In case of a failure before a test suite, the child results will be
recursively recorded as blocked, giving us a full report which was
missing previously.

Signed-off-by: Juraj Linkeš <juraj.linkes@pantheon.tech>
---
 dts/framework/runner.py      |  21 ++--
 dts/framework/test_result.py | 186 +++++++++++++++++++++++++----------
 2 files changed, 148 insertions(+), 59 deletions(-)

diff --git a/dts/framework/runner.py b/dts/framework/runner.py
index e8030365ac..511f8be064 100644
--- a/dts/framework/runner.py
+++ b/dts/framework/runner.py
@@ -60,13 +60,15 @@ class DTSRunner:
     Each setup or teardown of each stage is recorded in a :class:`~framework.test_result.DTSResult`
     or one of its subclasses. The test case results are also recorded.
 
-    If an error occurs, the current stage is aborted, the error is recorded and the run continues in
-    the next iteration of the same stage. The return code is the highest `severity` of all
+    If an error occurs, the current stage is aborted, the error is recorded, everything in
+    the inner stages is marked as blocked and the run continues in the next iteration
+    of the same stage. The return code is the highest `severity` of all
     :class:`~.framework.exception.DTSError`\s.
 
     Example:
-        An error occurs in a build target setup. The current build target is aborted and the run
-        continues with the next build target. If the errored build target was the last one in the
+        An error occurs in a build target setup. The current build target is aborted,
+        all test suites and their test cases are marked as blocked and the run continues
+        with the next build target. If the errored build target was the last one in the
         given execution, the next execution begins.
     """
 
@@ -100,6 +102,10 @@ def run(self):
         test case within the test suite is set up, executed and torn down. After all test cases
         have been executed, the test suite is torn down and the next build target will be tested.
 
+        In order to properly mark test suites and test cases as blocked in case of a failure,
+        we need to have discovered which test suites and test cases to run before any failures
+        happen. The discovery happens at the earliest point at the start of each execution.
+
         All the nested steps look like this:
 
             #. Execution setup
@@ -134,7 +140,7 @@ def run(self):
                 self._logger.info(
                     f"Running execution with SUT '{execution.system_under_test_node.name}'."
                 )
-                execution_result = self._result.add_execution(execution.system_under_test_node)
+                execution_result = self._result.add_execution(execution)
                 # we don't want to modify the original config, so create a copy
                 execution_test_suites = list(execution.test_suites)
                 if not execution.skip_smoke_tests:
@@ -143,6 +149,7 @@ def run(self):
                     test_suites_with_cases = self._get_test_suites_with_cases(
                         execution_test_suites, execution.func, execution.perf
                     )
+                    execution_result.test_suites_with_cases = test_suites_with_cases
                 except Exception as e:
                     self._logger.exception(
                         f"Invalid test suite configuration found: " f"{execution_test_suites}."
@@ -492,9 +499,7 @@ def _run_test_suites(
         """
         end_build_target = False
         for test_suite_with_cases in test_suites_with_cases:
-            test_suite_result = build_target_result.add_test_suite(
-                test_suite_with_cases.test_suite_class.__name__
-            )
+            test_suite_result = build_target_result.add_test_suite(test_suite_with_cases)
             try:
                 self._run_test_suite(sut_node, tg_node, test_suite_result, test_suite_with_cases)
             except BlockingTestSuiteError as e:
diff --git a/dts/framework/test_result.py b/dts/framework/test_result.py
index abdbafab10..eedb2d20ee 100644
--- a/dts/framework/test_result.py
+++ b/dts/framework/test_result.py
@@ -37,7 +37,7 @@
     BuildTargetInfo,
     Compiler,
     CPUType,
-    NodeConfiguration,
+    ExecutionConfiguration,
     NodeInfo,
     TestSuiteConfig,
 )
@@ -88,6 +88,8 @@ class Result(Enum):
     ERROR = auto()
     #:
     SKIP = auto()
+    #:
+    BLOCK = auto()
 
     def __bool__(self) -> bool:
         """Only PASS is True."""
@@ -141,21 +143,26 @@ class BaseResult(object):
     Attributes:
         setup_result: The result of the setup of the particular stage.
         teardown_result: The results of the teardown of the particular stage.
+        child_results: The results of the descendants in the results hierarchy.
     """
 
     setup_result: FixtureResult
     teardown_result: FixtureResult
-    _inner_results: MutableSequence["BaseResult"]
+    child_results: MutableSequence["BaseResult"]
 
     def __init__(self):
         """Initialize the constructor."""
         self.setup_result = FixtureResult()
         self.teardown_result = FixtureResult()
-        self._inner_results = []
+        self.child_results = []
 
     def update_setup(self, result: Result, error: Exception | None = None) -> None:
         """Store the setup result.
 
+        If the result is :attr:`~Result.BLOCK`, :attr:`~Result.ERROR` or :attr:`~Result.FAIL`,
+        then the corresponding child results in result hierarchy
+        are also marked with :attr:`~Result.BLOCK`.
+
         Args:
             result: The result of the setup.
             error: The error that occurred in case of a failure.
@@ -163,6 +170,16 @@ def update_setup(self, result: Result, error: Exception | None = None) -> None:
         self.setup_result.result = result
         self.setup_result.error = error
 
+        if result in [Result.BLOCK, Result.ERROR, Result.FAIL]:
+            self.update_teardown(Result.BLOCK)
+            self._block_result()
+
+    def _block_result(self) -> None:
+        r"""Mark the result as :attr:`~Result.BLOCK`\ed.
+
+        The blocking of child results should be done in overloaded methods.
+        """
+
     def update_teardown(self, result: Result, error: Exception | None = None) -> None:
         """Store the teardown result.
 
@@ -181,10 +198,8 @@ def _get_setup_teardown_errors(self) -> list[Exception]:
             errors.append(self.teardown_result.error)
         return errors
 
-    def _get_inner_errors(self) -> list[Exception]:
-        return [
-            error for inner_result in self._inner_results for error in inner_result.get_errors()
-        ]
+    def _get_child_errors(self) -> list[Exception]:
+        return [error for child_result in self.child_results for error in child_result.get_errors()]
 
     def get_errors(self) -> list[Exception]:
         """Compile errors from the whole result hierarchy.
@@ -192,7 +207,7 @@ def get_errors(self) -> list[Exception]:
         Returns:
             The errors from setup, teardown and all errors found in the whole result hierarchy.
         """
-        return self._get_setup_teardown_errors() + self._get_inner_errors()
+        return self._get_setup_teardown_errors() + self._get_child_errors()
 
     def add_stats(self, statistics: "Statistics") -> None:
         """Collate stats from the whole result hierarchy.
@@ -200,8 +215,8 @@ def add_stats(self, statistics: "Statistics") -> None:
         Args:
             statistics: The :class:`Statistics` object where the stats will be collated.
         """
-        for inner_result in self._inner_results:
-            inner_result.add_stats(statistics)
+        for child_result in self.child_results:
+            child_result.add_stats(statistics)
 
 
 class DTSResult(BaseResult):
@@ -242,18 +257,18 @@ def __init__(self, logger: DTSLOG):
         self._stats_result = None
         self._stats_filename = os.path.join(SETTINGS.output_dir, "statistics.txt")
 
-    def add_execution(self, sut_node: NodeConfiguration) -> "ExecutionResult":
-        """Add and return the inner result (execution).
+    def add_execution(self, execution: ExecutionConfiguration) -> "ExecutionResult":
+        """Add and return the child result (execution).
 
         Args:
-            sut_node: The SUT node's test run configuration.
+            execution: The execution's test run configuration.
 
         Returns:
             The execution's result.
         """
-        execution_result = ExecutionResult(sut_node)
-        self._inner_results.append(execution_result)
-        return execution_result
+        result = ExecutionResult(execution)
+        self.child_results.append(result)
+        return result
 
     def add_error(self, error: Exception) -> None:
         """Record an error that occurred outside any execution.
@@ -266,8 +281,8 @@ def add_error(self, error: Exception) -> None:
     def process(self) -> None:
         """Process the data after a whole DTS run.
 
-        The data is added to inner objects during runtime and this object is not updated
-        at that time. This requires us to process the inner data after it's all been gathered.
+        The data is added to child objects during runtime and this object is not updated
+        at that time. This requires us to process the child data after it's all been gathered.
 
         The processing gathers all errors and the statistics of test case results.
         """
@@ -305,28 +320,30 @@ class ExecutionResult(BaseResult):
     The internal list stores the results of all build targets in a given execution.
 
     Attributes:
-        sut_node: The SUT node used in the execution.
         sut_os_name: The operating system of the SUT node.
         sut_os_version: The operating system version of the SUT node.
         sut_kernel_version: The operating system kernel version of the SUT node.
     """
 
-    sut_node: NodeConfiguration
     sut_os_name: str
     sut_os_version: str
     sut_kernel_version: str
+    _config: ExecutionConfiguration
+    _parent_result: DTSResult
+    _test_suites_with_cases: list[TestSuiteWithCases]
 
-    def __init__(self, sut_node: NodeConfiguration):
-        """Extend the constructor with the `sut_node`'s config.
+    def __init__(self, execution: ExecutionConfiguration):
+        """Extend the constructor with the execution's config and DTSResult.
 
         Args:
-            sut_node: The SUT node's test run configuration used in the execution.
+            execution: The execution's test run configuration.
         """
         super(ExecutionResult, self).__init__()
-        self.sut_node = sut_node
+        self._config = execution
+        self._test_suites_with_cases = []
 
     def add_build_target(self, build_target: BuildTargetConfiguration) -> "BuildTargetResult":
-        """Add and return the inner result (build target).
+        """Add and return the child result (build target).
 
         Args:
             build_target: The build target's test run configuration.
@@ -334,9 +351,34 @@ def add_build_target(self, build_target: BuildTargetConfiguration) -> "BuildTarg
         Returns:
             The build target's result.
         """
-        build_target_result = BuildTargetResult(build_target)
-        self._inner_results.append(build_target_result)
-        return build_target_result
+        result = BuildTargetResult(
+            self._test_suites_with_cases,
+            build_target,
+        )
+        self.child_results.append(result)
+        return result
+
+    @property
+    def test_suites_with_cases(self) -> list[TestSuiteWithCases]:
+        """The test suites with test cases to be executed in this execution.
+
+        The test suites can only be assigned once.
+
+        Returns:
+            The list of test suites with test cases. If an error occurs between
+            the initialization of :class:`ExecutionResult` and assigning test cases to the instance,
+            return an empty list, representing that we don't know what to execute.
+        """
+        return self._test_suites_with_cases
+
+    @test_suites_with_cases.setter
+    def test_suites_with_cases(self, test_suites_with_cases: list[TestSuiteWithCases]) -> None:
+        if self._test_suites_with_cases:
+            raise ValueError(
+                "Attempted to assign test suites to an execution result "
+                "which already has test suites."
+            )
+        self._test_suites_with_cases = test_suites_with_cases
 
     def add_sut_info(self, sut_info: NodeInfo) -> None:
         """Add SUT information gathered at runtime.
@@ -348,6 +390,12 @@ def add_sut_info(self, sut_info: NodeInfo) -> None:
         self.sut_os_version = sut_info.os_version
         self.sut_kernel_version = sut_info.kernel_version
 
+    def _block_result(self) -> None:
+        r"""Mark the result as :attr:`~Result.BLOCK`\ed."""
+        for build_target in self._config.build_targets:
+            child_result = self.add_build_target(build_target)
+            child_result.update_setup(Result.BLOCK)
+
 
 class BuildTargetResult(BaseResult):
     """The build target specific result.
@@ -369,11 +417,17 @@ class BuildTargetResult(BaseResult):
     compiler: Compiler
     compiler_version: str | None
     dpdk_version: str | None
+    _test_suites_with_cases: list[TestSuiteWithCases]
 
-    def __init__(self, build_target: BuildTargetConfiguration):
-        """Extend the constructor with the `build_target`'s build target config.
+    def __init__(
+        self,
+        test_suites_with_cases: list[TestSuiteWithCases],
+        build_target: BuildTargetConfiguration,
+    ):
+        """Extend the constructor with the build target's config and ExecutionResult.
 
         Args:
+            test_suites_with_cases: The test suites with test cases to be run in this build target.
             build_target: The build target's test run configuration.
         """
         super(BuildTargetResult, self).__init__()
@@ -383,6 +437,23 @@ def __init__(self, build_target: BuildTargetConfiguration):
         self.compiler = build_target.compiler
         self.compiler_version = None
         self.dpdk_version = None
+        self._test_suites_with_cases = test_suites_with_cases
+
+    def add_test_suite(
+        self,
+        test_suite_with_cases: TestSuiteWithCases,
+    ) -> "TestSuiteResult":
+        """Add and return the child result (test suite).
+
+        Args:
+            test_suite_with_cases: The test suite with test cases.
+
+        Returns:
+            The test suite's result.
+        """
+        result = TestSuiteResult(test_suite_with_cases)
+        self.child_results.append(result)
+        return result
 
     def add_build_target_info(self, versions: BuildTargetInfo) -> None:
         """Add information about the build target gathered at runtime.
@@ -393,15 +464,11 @@ def add_build_target_info(self, versions: BuildTargetInfo) -> None:
         self.compiler_version = versions.compiler_version
         self.dpdk_version = versions.dpdk_version
 
-    def add_test_suite(self, test_suite_name: str) -> "TestSuiteResult":
-        """Add and return the inner result (test suite).
-
-        Returns:
-            The test suite's result.
-        """
-        test_suite_result = TestSuiteResult(test_suite_name)
-        self._inner_results.append(test_suite_result)
-        return test_suite_result
+    def _block_result(self) -> None:
+        r"""Mark the result as :attr:`~Result.BLOCK`\ed."""
+        for test_suite_with_cases in self._test_suites_with_cases:
+            child_result = self.add_test_suite(test_suite_with_cases)
+            child_result.update_setup(Result.BLOCK)
 
 
 class TestSuiteResult(BaseResult):
@@ -410,29 +477,42 @@ class TestSuiteResult(BaseResult):
     The internal list stores the results of all test cases in a given test suite.
 
     Attributes:
-        suite_name: The test suite name.
+        test_suite_name: The test suite name.
     """
 
-    suite_name: str
+    test_suite_name: str
+    _test_suite_with_cases: TestSuiteWithCases
+    _parent_result: BuildTargetResult
+    _child_configs: list[str]
 
-    def __init__(self, suite_name: str):
-        """Extend the constructor with `suite_name`.
+    def __init__(self, test_suite_with_cases: TestSuiteWithCases):
+        """Extend the constructor with test suite's config and BuildTargetResult.
 
         Args:
-            suite_name: The test suite's name.
+            test_suite_with_cases: The test suite with test cases.
         """
         super(TestSuiteResult, self).__init__()
-        self.suite_name = suite_name
+        self.test_suite_name = test_suite_with_cases.test_suite_class.__name__
+        self._test_suite_with_cases = test_suite_with_cases
 
     def add_test_case(self, test_case_name: str) -> "TestCaseResult":
-        """Add and return the inner result (test case).
+        """Add and return the child result (test case).
+
+        Args:
+            test_case_name: The name of the test case.
 
         Returns:
             The test case's result.
         """
-        test_case_result = TestCaseResult(test_case_name)
-        self._inner_results.append(test_case_result)
-        return test_case_result
+        result = TestCaseResult(test_case_name)
+        self.child_results.append(result)
+        return result
+
+    def _block_result(self) -> None:
+        r"""Mark the result as :attr:`~Result.BLOCK`\ed."""
+        for test_case_method in self._test_suite_with_cases.test_cases:
+            child_result = self.add_test_case(test_case_method.__name__)
+            child_result.update_setup(Result.BLOCK)
 
 
 class TestCaseResult(BaseResult, FixtureResult):
@@ -449,7 +529,7 @@ class TestCaseResult(BaseResult, FixtureResult):
     test_case_name: str
 
     def __init__(self, test_case_name: str):
-        """Extend the constructor with `test_case_name`.
+        """Extend the constructor with test case's name and TestSuiteResult.
 
         Args:
             test_case_name: The test case's name.
@@ -470,7 +550,7 @@ def update(self, result: Result, error: Exception | None = None) -> None:
         self.result = result
         self.error = error
 
-    def _get_inner_errors(self) -> list[Exception]:
+    def _get_child_errors(self) -> list[Exception]:
         if self.error:
             return [self.error]
         return []
@@ -486,6 +566,10 @@ def add_stats(self, statistics: "Statistics") -> None:
         """
         statistics += self.result
 
+    def _block_result(self) -> None:
+        r"""Mark the result as :attr:`~Result.BLOCK`\ed."""
+        self.update(Result.BLOCK)
+
     def __bool__(self) -> bool:
         """The test case passed only if setup, teardown and the test case itself passed."""
         return bool(self.setup_result) and bool(self.teardown_result) and bool(self.result)
-- 
2.34.1


^ permalink raw reply	[flat|nested] 28+ messages in thread

* [PATCH v3 6/7] dts: refactor logging configuration
  2024-02-23  7:54 ` [PATCH v3 0/7] test case blocking and logging Juraj Linkeš
                     ` (4 preceding siblings ...)
  2024-02-23  7:55   ` [PATCH v3 5/7] dts: block all test cases when earlier setup fails Juraj Linkeš
@ 2024-02-23  7:55   ` Juraj Linkeš
  2024-02-23  7:55   ` [PATCH v3 7/7] dts: improve test suite and case filtering Juraj Linkeš
  6 siblings, 0 replies; 28+ messages in thread
From: Juraj Linkeš @ 2024-02-23  7:55 UTC (permalink / raw)
  To: thomas, Honnappa.Nagarahalli, jspewock, probb, paul.szczepanek,
	Luca.Vizzarro
  Cc: dev, Juraj Linkeš

Remove unused parts of the code and add useful features:
1. Add DTS execution stages such as execution and test suite to better
   identify where in the DTS lifecycle we are when investigating logs,
2. Logging to separate files in specific stages, which is mainly useful
   for having test suite logs in additional separate files.
3. Remove the dependence on the settings module which enhances the
   usefulness of the logger module, as it can now be imported in more
   modules.

The execution stages and the files to log to are the same for all DTS
loggers. To achieve this, we have one DTS root logger which should be
used for handling stage switching and all other loggers are children of
this DTS root logger. The DTS root logger is the one where we change the
behavior of all loggers (the stage and which files to log to) and the
child loggers just log messages under a different name.

Signed-off-by: Juraj Linkeš <juraj.linkes@pantheon.tech>
---
 dts/framework/logger.py                       | 236 +++++++++++-------
 dts/framework/remote_session/__init__.py      |   6 +-
 .../interactive_remote_session.py             |   6 +-
 .../remote_session/interactive_shell.py       |   6 +-
 .../remote_session/remote_session.py          |   8 +-
 dts/framework/runner.py                       |  19 +-
 dts/framework/test_result.py                  |   6 +-
 dts/framework/test_suite.py                   |   6 +-
 dts/framework/testbed_model/node.py           |  11 +-
 dts/framework/testbed_model/os_session.py     |   7 +-
 .../traffic_generator/traffic_generator.py    |   6 +-
 dts/main.py                                   |   3 -
 12 files changed, 184 insertions(+), 136 deletions(-)

diff --git a/dts/framework/logger.py b/dts/framework/logger.py
index cfa6e8cd72..db4a7698e0 100644
--- a/dts/framework/logger.py
+++ b/dts/framework/logger.py
@@ -5,141 +5,187 @@
 
 """DTS logger module.
 
-DTS framework and TestSuite logs are saved in different log files.
+The module provides several additional features:
+
+    * The storage of DTS execution stages,
+    * Logging to console, a human-readable log file and a machine-readable log file,
+    * Optional log files for specific stages.
 """
 
 import logging
-import os.path
-from typing import TypedDict
+from enum import auto
+from logging import FileHandler, StreamHandler
+from pathlib import Path
+from typing import ClassVar
 
-from .settings import SETTINGS
+from .utils import StrEnum
 
 date_fmt = "%Y/%m/%d %H:%M:%S"
-stream_fmt = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
+stream_fmt = "%(asctime)s - %(stage)s - %(name)s - %(levelname)s - %(message)s"
+dts_root_logger_name = "dts"
+
+
+class DtsStage(StrEnum):
+    """The DTS execution stage."""
 
+    #:
+    pre_execution = auto()
+    #:
+    execution = auto()
+    #:
+    build_target = auto()
+    #:
+    suite = auto()
+    #:
+    post_execution = auto()
 
-class DTSLOG(logging.LoggerAdapter):
-    """DTS logger adapter class for framework and testsuites.
 
-    The :option:`--verbose` command line argument and the :envvar:`DTS_VERBOSE` environment
-    variable control the verbosity of output. If enabled, all messages will be emitted to the
-    console.
+class DTSLogger(logging.Logger):
+    """The DTS logger class.
 
-    The :option:`--output` command line argument and the :envvar:`DTS_OUTPUT_DIR` environment
-    variable modify the directory where the logs will be stored.
+    The class extends the :class:`~logging.Logger` class to add the DTS execution stage information
+    to log records. The stage is common to all loggers, so it's stored in a class variable.
 
-    Attributes:
-        node: The additional identifier. Currently unused.
-        sh: The handler which emits logs to console.
-        fh: The handler which emits logs to a file.
-        verbose_fh: Just as fh, but logs with a different, more verbose, format.
+    Any time we switch to a new stage, we have the ability to log to an additional log file along
+    with a supplementary log file with machine-readable format. These two log files are used until
+    a new stage switch occurs. This is useful mainly for logging per test suite.
     """
 
-    _logger: logging.Logger
-    node: str
-    sh: logging.StreamHandler
-    fh: logging.FileHandler
-    verbose_fh: logging.FileHandler
+    _stage: ClassVar[DtsStage] = DtsStage.pre_execution
+    _extra_file_handlers: list[FileHandler] = []
 
-    def __init__(self, logger: logging.Logger, node: str = "suite"):
-        """Extend the constructor with additional handlers.
+    def __init__(self, *args, **kwargs):
+        """Extend the constructor with extra file handlers."""
+        self._extra_file_handlers = []
+        super().__init__(*args, **kwargs)
 
-        One handler logs to the console, the other one to a file, with either a regular or verbose
-        format.
+    def makeRecord(self, *args, **kwargs) -> logging.LogRecord:
+        """Generates a record with additional stage information.
 
-        Args:
-            logger: The logger from which to create the logger adapter.
-            node: An additional identifier. Currently unused.
+        This is the default method for the :class:`~logging.Logger` class. We extend it
+        to add stage information to the record.
+
+        :meta private:
+
+        Returns:
+            record: The generated record with the stage information.
         """
-        self._logger = logger
-        # 1 means log everything, this will be used by file handlers if their level
-        # is not set
-        self._logger.setLevel(1)
+        record = super().makeRecord(*args, **kwargs)
+        record.stage = DTSLogger._stage  # type: ignore[attr-defined]
+        return record
+
+    def add_dts_root_logger_handlers(self, verbose: bool, output_dir: str) -> None:
+        """Add logger handlers to the DTS root logger.
+
+        This method should be called only on the DTS root logger.
+        The log records from child loggers will propagate to these handlers.
+
+        Three handlers are added:
 
-        self.node = node
+            * A console handler,
+            * A file handler,
+            * A supplementary file handler with machine-readable logs
+              containing more debug information.
 
-        # add handler to emit to stdout
-        sh = logging.StreamHandler()
+        All log messages will be logged to files. The log level of the console handler
+        is configurable with `verbose`.
+
+        Args:
+            verbose: If :data:`True`, log all messages to the console.
+                If :data:`False`, log to console with the :data:`logging.INFO` level.
+            output_dir: The directory where the log files will be located.
+                The names of the log files correspond to the name of the logger instance.
+        """
+        self.setLevel(1)
+
+        sh = StreamHandler()
         sh.setFormatter(logging.Formatter(stream_fmt, date_fmt))
-        sh.setLevel(logging.INFO)  # console handler default level
+        if not verbose:
+            sh.setLevel(logging.INFO)
+        self.addHandler(sh)
 
-        if SETTINGS.verbose is True:
-            sh.setLevel(logging.DEBUG)
+        self._add_file_handlers(Path(output_dir, self.name))
 
-        self._logger.addHandler(sh)
-        self.sh = sh
+    def set_stage(self, stage: DtsStage, log_file_path: Path | None = None) -> None:
+        """Set the DTS execution stage and optionally log to files.
 
-        # prepare the output folder
-        if not os.path.exists(SETTINGS.output_dir):
-            os.mkdir(SETTINGS.output_dir)
+        Set the DTS execution stage of the DTSLog class and optionally add
+        file handlers to the instance if the log file name is provided.
 
-        logging_path_prefix = os.path.join(SETTINGS.output_dir, node)
+        The file handlers log all messages. One is a regular human-readable log file and
+        the other one is a machine-readable log file with extra debug information.
 
-        fh = logging.FileHandler(f"{logging_path_prefix}.log")
-        fh.setFormatter(
-            logging.Formatter(
-                fmt="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
-                datefmt=date_fmt,
-            )
-        )
+        Args:
+            stage: The DTS stage to set.
+            log_file_path: An optional path of the log file to use. This should be a full path
+                (either relative or absolute) without suffix (which will be appended).
+        """
+        self._remove_extra_file_handlers()
+
+        if DTSLogger._stage != stage:
+            self.info(f"Moving from stage '{DTSLogger._stage}' to stage '{stage}'.")
+            DTSLogger._stage = stage
+
+        if log_file_path:
+            self._extra_file_handlers.extend(self._add_file_handlers(log_file_path))
+
+    def _add_file_handlers(self, log_file_path: Path) -> list[FileHandler]:
+        """Add file handlers to the DTS root logger.
+
+        Add two type of file handlers:
+
+            * A regular file handler with suffix ".log",
+            * A machine-readable file handler with suffix ".verbose.log".
+              This format provides extensive information for debugging and detailed analysis.
+
+        Args:
+            log_file_path: The full path to the log file without suffix.
+
+        Returns:
+            The newly created file handlers.
 
-        self._logger.addHandler(fh)
-        self.fh = fh
+        """
+        fh = FileHandler(f"{log_file_path}.log")
+        fh.setFormatter(logging.Formatter(stream_fmt, date_fmt))
+        self.addHandler(fh)
 
-        # This outputs EVERYTHING, intended for post-mortem debugging
-        # Also optimized for processing via AWK (awk -F '|' ...)
-        verbose_fh = logging.FileHandler(f"{logging_path_prefix}.verbose.log")
+        verbose_fh = FileHandler(f"{log_file_path}.verbose.log")
         verbose_fh.setFormatter(
             logging.Formatter(
-                fmt="%(asctime)s|%(name)s|%(levelname)s|%(pathname)s|%(lineno)d|"
+                "%(asctime)s|%(stage)s|%(name)s|%(levelname)s|%(pathname)s|%(lineno)d|"
                 "%(funcName)s|%(process)d|%(thread)d|%(threadName)s|%(message)s",
                 datefmt=date_fmt,
             )
         )
+        self.addHandler(verbose_fh)
 
-        self._logger.addHandler(verbose_fh)
-        self.verbose_fh = verbose_fh
-
-        super(DTSLOG, self).__init__(self._logger, dict(node=self.node))
-
-    def logger_exit(self) -> None:
-        """Remove the stream handler and the logfile handler."""
-        for handler in (self.sh, self.fh, self.verbose_fh):
-            handler.flush()
-            self._logger.removeHandler(handler)
-
-
-class _LoggerDictType(TypedDict):
-    logger: DTSLOG
-    name: str
-    node: str
-
+        return [fh, verbose_fh]
 
-# List for saving all loggers in use
-_Loggers: list[_LoggerDictType] = []
+    def _remove_extra_file_handlers(self) -> None:
+        """Remove any extra file handlers that have been added to the logger."""
+        if self._extra_file_handlers:
+            for extra_file_handler in self._extra_file_handlers:
+                self.removeHandler(extra_file_handler)
 
+            self._extra_file_handlers = []
 
-def getLogger(name: str, node: str = "suite") -> DTSLOG:
-    """Get DTS logger adapter identified by name and node.
 
-    An existing logger will be returned if one with the exact name and node already exists.
-    A new one will be created and stored otherwise.
+def get_dts_logger(name: str = None) -> DTSLogger:
+    """Return a DTS logger instance identified by `name`.
 
     Args:
-        name: The name of the logger.
-        node: An additional identifier for the logger.
+        name: If :data:`None`, return the DTS root logger.
+            If specified, return a child of the DTS root logger.
 
     Returns:
-        A logger uniquely identified by both name and node.
+         The DTS root logger or a child logger identified by `name`.
     """
-    global _Loggers
-    # return saved logger
-    logger: _LoggerDictType
-    for logger in _Loggers:
-        if logger["name"] == name and logger["node"] == node:
-            return logger["logger"]
-
-    # return new logger
-    dts_logger: DTSLOG = DTSLOG(logging.getLogger(name), node)
-    _Loggers.append({"logger": dts_logger, "name": name, "node": node})
-    return dts_logger
+    original_logger_class = logging.getLoggerClass()
+    logging.setLoggerClass(DTSLogger)
+    if name:
+        name = f"{dts_root_logger_name}.{name}"
+    else:
+        name = dts_root_logger_name
+    logger = logging.getLogger(name)
+    logging.setLoggerClass(original_logger_class)
+    return logger  # type: ignore[return-value]
diff --git a/dts/framework/remote_session/__init__.py b/dts/framework/remote_session/__init__.py
index 51a01d6b5e..1910c81c3c 100644
--- a/dts/framework/remote_session/__init__.py
+++ b/dts/framework/remote_session/__init__.py
@@ -15,7 +15,7 @@
 # pylama:ignore=W0611
 
 from framework.config import NodeConfiguration
-from framework.logger import DTSLOG
+from framework.logger import DTSLogger
 
 from .interactive_remote_session import InteractiveRemoteSession
 from .interactive_shell import InteractiveShell
@@ -26,7 +26,7 @@
 
 
 def create_remote_session(
-    node_config: NodeConfiguration, name: str, logger: DTSLOG
+    node_config: NodeConfiguration, name: str, logger: DTSLogger
 ) -> RemoteSession:
     """Factory for non-interactive remote sessions.
 
@@ -45,7 +45,7 @@ def create_remote_session(
 
 
 def create_interactive_session(
-    node_config: NodeConfiguration, logger: DTSLOG
+    node_config: NodeConfiguration, logger: DTSLogger
 ) -> InteractiveRemoteSession:
     """Factory for interactive remote sessions.
 
diff --git a/dts/framework/remote_session/interactive_remote_session.py b/dts/framework/remote_session/interactive_remote_session.py
index 1cc82e3377..c50790db79 100644
--- a/dts/framework/remote_session/interactive_remote_session.py
+++ b/dts/framework/remote_session/interactive_remote_session.py
@@ -16,7 +16,7 @@
 
 from framework.config import NodeConfiguration
 from framework.exception import SSHConnectionError
-from framework.logger import DTSLOG
+from framework.logger import DTSLogger
 
 
 class InteractiveRemoteSession:
@@ -50,11 +50,11 @@ class InteractiveRemoteSession:
     username: str
     password: str
     session: SSHClient
-    _logger: DTSLOG
+    _logger: DTSLogger
     _node_config: NodeConfiguration
     _transport: Transport | None
 
-    def __init__(self, node_config: NodeConfiguration, logger: DTSLOG) -> None:
+    def __init__(self, node_config: NodeConfiguration, logger: DTSLogger) -> None:
         """Connect to the node during initialization.
 
         Args:
diff --git a/dts/framework/remote_session/interactive_shell.py b/dts/framework/remote_session/interactive_shell.py
index b158f963b6..5cfe202e15 100644
--- a/dts/framework/remote_session/interactive_shell.py
+++ b/dts/framework/remote_session/interactive_shell.py
@@ -20,7 +20,7 @@
 
 from paramiko import Channel, SSHClient, channel  # type: ignore[import]
 
-from framework.logger import DTSLOG
+from framework.logger import DTSLogger
 from framework.settings import SETTINGS
 
 
@@ -38,7 +38,7 @@ class InteractiveShell(ABC):
     _stdin: channel.ChannelStdinFile
     _stdout: channel.ChannelFile
     _ssh_channel: Channel
-    _logger: DTSLOG
+    _logger: DTSLogger
     _timeout: float
     _app_args: str
 
@@ -61,7 +61,7 @@ class InteractiveShell(ABC):
     def __init__(
         self,
         interactive_session: SSHClient,
-        logger: DTSLOG,
+        logger: DTSLogger,
         get_privileged_command: Callable[[str], str] | None,
         app_args: str = "",
         timeout: float = SETTINGS.timeout,
diff --git a/dts/framework/remote_session/remote_session.py b/dts/framework/remote_session/remote_session.py
index 2059f9a981..a69dc99400 100644
--- a/dts/framework/remote_session/remote_session.py
+++ b/dts/framework/remote_session/remote_session.py
@@ -9,14 +9,13 @@
 the structure of the result of a command execution.
 """
 
-
 import dataclasses
 from abc import ABC, abstractmethod
 from pathlib import PurePath
 
 from framework.config import NodeConfiguration
 from framework.exception import RemoteCommandExecutionError
-from framework.logger import DTSLOG
+from framework.logger import DTSLogger
 from framework.settings import SETTINGS
 
 
@@ -75,14 +74,14 @@ class RemoteSession(ABC):
     username: str
     password: str
     history: list[CommandResult]
-    _logger: DTSLOG
+    _logger: DTSLogger
     _node_config: NodeConfiguration
 
     def __init__(
         self,
         node_config: NodeConfiguration,
         session_name: str,
-        logger: DTSLOG,
+        logger: DTSLogger,
     ):
         """Connect to the node during initialization.
 
@@ -181,7 +180,6 @@ def close(self, force: bool = False) -> None:
         Args:
             force: Force the closure of the connection. This may not clean up all resources.
         """
-        self._logger.logger_exit()
         self._close(force)
 
     @abstractmethod
diff --git a/dts/framework/runner.py b/dts/framework/runner.py
index 511f8be064..af927b11a9 100644
--- a/dts/framework/runner.py
+++ b/dts/framework/runner.py
@@ -19,9 +19,10 @@
 
 import importlib
 import inspect
-import logging
+import os
 import re
 import sys
+from pathlib import Path
 from types import MethodType
 from typing import Iterable
 
@@ -38,7 +39,7 @@
     SSHTimeoutError,
     TestCaseVerifyError,
 )
-from .logger import DTSLOG, getLogger
+from .logger import DTSLogger, DtsStage, get_dts_logger
 from .settings import SETTINGS
 from .test_result import (
     BuildTargetResult,
@@ -73,7 +74,7 @@ class DTSRunner:
     """
 
     _configuration: Configuration
-    _logger: DTSLOG
+    _logger: DTSLogger
     _result: DTSResult
     _test_suite_class_prefix: str
     _test_suite_module_prefix: str
@@ -83,7 +84,10 @@ class DTSRunner:
     def __init__(self):
         """Initialize the instance with configuration, logger, result and string constants."""
         self._configuration = load_config()
-        self._logger = getLogger("DTSRunner")
+        self._logger = get_dts_logger()
+        if not os.path.exists(SETTINGS.output_dir):
+            os.makedirs(SETTINGS.output_dir)
+        self._logger.add_dts_root_logger_handlers(SETTINGS.verbose, SETTINGS.output_dir)
         self._result = DTSResult(self._logger)
         self._test_suite_class_prefix = "Test"
         self._test_suite_module_prefix = "tests.TestSuite_"
@@ -137,6 +141,7 @@ def run(self):
 
             # for all Execution sections
             for execution in self._configuration.executions:
+                self._logger.set_stage(DtsStage.execution)
                 self._logger.info(
                     f"Running execution with SUT '{execution.system_under_test_node.name}'."
                 )
@@ -168,6 +173,7 @@ def run(self):
 
         finally:
             try:
+                self._logger.set_stage(DtsStage.post_execution)
                 for node in (sut_nodes | tg_nodes).values():
                     node.close()
                 self._result.update_teardown(Result.PASS)
@@ -425,6 +431,7 @@ def _run_execution(
 
         finally:
             try:
+                self._logger.set_stage(DtsStage.execution)
                 sut_node.tear_down_execution()
                 execution_result.update_teardown(Result.PASS)
             except Exception as e:
@@ -453,6 +460,7 @@ def _run_build_target(
                 with the current build target.
             test_suites_with_cases: The test suites with test cases to run.
         """
+        self._logger.set_stage(DtsStage.build_target)
         self._logger.info(f"Running build target '{build_target.name}'.")
 
         try:
@@ -469,6 +477,7 @@ def _run_build_target(
 
         finally:
             try:
+                self._logger.set_stage(DtsStage.build_target)
                 sut_node.tear_down_build_target()
                 build_target_result.update_teardown(Result.PASS)
             except Exception as e:
@@ -541,6 +550,7 @@ def _run_test_suite(
             BlockingTestSuiteError: If a blocking test suite fails.
         """
         test_suite_name = test_suite_with_cases.test_suite_class.__name__
+        self._logger.set_stage(DtsStage.suite, Path(SETTINGS.output_dir, test_suite_name))
         test_suite = test_suite_with_cases.test_suite_class(sut_node, tg_node)
         try:
             self._logger.info(f"Starting test suite setup: {test_suite_name}")
@@ -689,5 +699,4 @@ def _exit_dts(self) -> None:
         if self._logger:
             self._logger.info("DTS execution has ended.")
 
-        logging.shutdown()
         sys.exit(self._result.get_return_code())
diff --git a/dts/framework/test_result.py b/dts/framework/test_result.py
index eedb2d20ee..28f84fd793 100644
--- a/dts/framework/test_result.py
+++ b/dts/framework/test_result.py
@@ -42,7 +42,7 @@
     TestSuiteConfig,
 )
 from .exception import DTSError, ErrorSeverity
-from .logger import DTSLOG
+from .logger import DTSLogger
 from .settings import SETTINGS
 from .test_suite import TestSuite
 
@@ -237,13 +237,13 @@ class DTSResult(BaseResult):
     """
 
     dpdk_version: str | None
-    _logger: DTSLOG
+    _logger: DTSLogger
     _errors: list[Exception]
     _return_code: ErrorSeverity
     _stats_result: Union["Statistics", None]
     _stats_filename: str
 
-    def __init__(self, logger: DTSLOG):
+    def __init__(self, logger: DTSLogger):
         """Extend the constructor with top-level specifics.
 
         Args:
diff --git a/dts/framework/test_suite.py b/dts/framework/test_suite.py
index f9fe88093e..365f80e21a 100644
--- a/dts/framework/test_suite.py
+++ b/dts/framework/test_suite.py
@@ -21,7 +21,7 @@
 from scapy.packet import Packet, Padding  # type: ignore[import]
 
 from .exception import TestCaseVerifyError
-from .logger import DTSLOG, getLogger
+from .logger import DTSLogger, get_dts_logger
 from .testbed_model import Port, PortLink, SutNode, TGNode
 from .utils import get_packet_summaries
 
@@ -61,7 +61,7 @@ class TestSuite(object):
     #: Whether the test suite is blocking. A failure of a blocking test suite
     #: will block the execution of all subsequent test suites in the current build target.
     is_blocking: ClassVar[bool] = False
-    _logger: DTSLOG
+    _logger: DTSLogger
     _port_links: list[PortLink]
     _sut_port_ingress: Port
     _sut_port_egress: Port
@@ -88,7 +88,7 @@ def __init__(
         """
         self.sut_node = sut_node
         self.tg_node = tg_node
-        self._logger = getLogger(self.__class__.__name__)
+        self._logger = get_dts_logger(self.__class__.__name__)
         self._port_links = []
         self._process_links()
         self._sut_port_ingress, self._tg_port_egress = (
diff --git a/dts/framework/testbed_model/node.py b/dts/framework/testbed_model/node.py
index 1a55fadf78..74061f6262 100644
--- a/dts/framework/testbed_model/node.py
+++ b/dts/framework/testbed_model/node.py
@@ -23,7 +23,7 @@
     NodeConfiguration,
 )
 from framework.exception import ConfigurationError
-from framework.logger import DTSLOG, getLogger
+from framework.logger import DTSLogger, get_dts_logger
 from framework.settings import SETTINGS
 
 from .cpu import (
@@ -63,7 +63,7 @@ class Node(ABC):
     name: str
     lcores: list[LogicalCore]
     ports: list[Port]
-    _logger: DTSLOG
+    _logger: DTSLogger
     _other_sessions: list[OSSession]
     _execution_config: ExecutionConfiguration
     virtual_devices: list[VirtualDevice]
@@ -82,7 +82,7 @@ def __init__(self, node_config: NodeConfiguration):
         """
         self.config = node_config
         self.name = node_config.name
-        self._logger = getLogger(self.name)
+        self._logger = get_dts_logger(self.name)
         self.main_session = create_session(self.config, self.name, self._logger)
 
         self._logger.info(f"Connected to node: {self.name}")
@@ -189,7 +189,7 @@ def create_session(self, name: str) -> OSSession:
         connection = create_session(
             self.config,
             session_name,
-            getLogger(session_name, node=self.name),
+            get_dts_logger(session_name),
         )
         self._other_sessions.append(connection)
         return connection
@@ -299,7 +299,6 @@ def close(self) -> None:
             self.main_session.close()
         for session in self._other_sessions:
             session.close()
-        self._logger.logger_exit()
 
     @staticmethod
     def skip_setup(func: Callable[..., Any]) -> Callable[..., Any]:
@@ -314,7 +313,7 @@ def skip_setup(func: Callable[..., Any]) -> Callable[..., Any]:
             return func
 
 
-def create_session(node_config: NodeConfiguration, name: str, logger: DTSLOG) -> OSSession:
+def create_session(node_config: NodeConfiguration, name: str, logger: DTSLogger) -> OSSession:
     """Factory for OS-aware sessions.
 
     Args:
diff --git a/dts/framework/testbed_model/os_session.py b/dts/framework/testbed_model/os_session.py
index ac6bb5e112..6983aa4a77 100644
--- a/dts/framework/testbed_model/os_session.py
+++ b/dts/framework/testbed_model/os_session.py
@@ -21,7 +21,6 @@
     the :attr:`~.node.Node.main_session` translates that to ``rm -rf`` if the node's OS is Linux
     and other commands for other OSs. It also translates the path to match the underlying OS.
 """
-
 from abc import ABC, abstractmethod
 from collections.abc import Iterable
 from ipaddress import IPv4Interface, IPv6Interface
@@ -29,7 +28,7 @@
 from typing import Type, TypeVar, Union
 
 from framework.config import Architecture, NodeConfiguration, NodeInfo
-from framework.logger import DTSLOG
+from framework.logger import DTSLogger
 from framework.remote_session import (
     CommandResult,
     InteractiveRemoteSession,
@@ -62,7 +61,7 @@ class OSSession(ABC):
 
     _config: NodeConfiguration
     name: str
-    _logger: DTSLOG
+    _logger: DTSLogger
     remote_session: RemoteSession
     interactive_session: InteractiveRemoteSession
 
@@ -70,7 +69,7 @@ def __init__(
         self,
         node_config: NodeConfiguration,
         name: str,
-        logger: DTSLOG,
+        logger: DTSLogger,
     ):
         """Initialize the OS-aware session.
 
diff --git a/dts/framework/testbed_model/traffic_generator/traffic_generator.py b/dts/framework/testbed_model/traffic_generator/traffic_generator.py
index c49fbff488..d86d7fb532 100644
--- a/dts/framework/testbed_model/traffic_generator/traffic_generator.py
+++ b/dts/framework/testbed_model/traffic_generator/traffic_generator.py
@@ -13,7 +13,7 @@
 from scapy.packet import Packet  # type: ignore[import]
 
 from framework.config import TrafficGeneratorConfig
-from framework.logger import DTSLOG, getLogger
+from framework.logger import DTSLogger, get_dts_logger
 from framework.testbed_model.node import Node
 from framework.testbed_model.port import Port
 from framework.utils import get_packet_summaries
@@ -28,7 +28,7 @@ class TrafficGenerator(ABC):
 
     _config: TrafficGeneratorConfig
     _tg_node: Node
-    _logger: DTSLOG
+    _logger: DTSLogger
 
     def __init__(self, tg_node: Node, config: TrafficGeneratorConfig):
         """Initialize the traffic generator.
@@ -39,7 +39,7 @@ def __init__(self, tg_node: Node, config: TrafficGeneratorConfig):
         """
         self._config = config
         self._tg_node = tg_node
-        self._logger = getLogger(f"{self._tg_node.name} {self._config.traffic_generator_type}")
+        self._logger = get_dts_logger(f"{self._tg_node.name} {self._config.traffic_generator_type}")
 
     def send_packet(self, packet: Packet, port: Port) -> None:
         """Send `packet` and block until it is fully sent.
diff --git a/dts/main.py b/dts/main.py
index 1ffe8ff81f..fa878cc16e 100755
--- a/dts/main.py
+++ b/dts/main.py
@@ -6,8 +6,6 @@
 
 """The DTS executable."""
 
-import logging
-
 from framework import settings
 
 
@@ -30,5 +28,4 @@ def main() -> None:
 
 # Main program begins here
 if __name__ == "__main__":
-    logging.raiseExceptions = True
     main()
-- 
2.34.1


^ permalink raw reply	[flat|nested] 28+ messages in thread

* [PATCH v3 7/7] dts: improve test suite and case filtering
  2024-02-23  7:54 ` [PATCH v3 0/7] test case blocking and logging Juraj Linkeš
                     ` (5 preceding siblings ...)
  2024-02-23  7:55   ` [PATCH v3 6/7] dts: refactor logging configuration Juraj Linkeš
@ 2024-02-23  7:55   ` Juraj Linkeš
  6 siblings, 0 replies; 28+ messages in thread
From: Juraj Linkeš @ 2024-02-23  7:55 UTC (permalink / raw)
  To: thomas, Honnappa.Nagarahalli, jspewock, probb, paul.szczepanek,
	Luca.Vizzarro
  Cc: dev, Juraj Linkeš

The two places where we specify which test suite and test cases to run
are complimentary and not that intuitive to use. A unified way provides
a better user experience.

The syntax in test run configuration file has not changed, but the
environment variable and the command line arguments was changed to match
the config file syntax. This required changes in the settings module
which greatly simplified the parsing of the environment variables while
retaining the same functionality.

Signed-off-by: Juraj Linkeš <juraj.linkes@pantheon.tech>
---
 doc/guides/tools/dts.rst         |  14 ++-
 dts/framework/config/__init__.py |  12 +-
 dts/framework/runner.py          |  18 +--
 dts/framework/settings.py        | 187 ++++++++++++++-----------------
 dts/framework/test_suite.py      |   2 +-
 5 files changed, 114 insertions(+), 119 deletions(-)

diff --git a/doc/guides/tools/dts.rst b/doc/guides/tools/dts.rst
index f686ca487c..d1c3c2af7a 100644
--- a/doc/guides/tools/dts.rst
+++ b/doc/guides/tools/dts.rst
@@ -215,28 +215,30 @@ DTS is run with ``main.py`` located in the ``dts`` directory after entering Poet
 .. code-block:: console
 
    (dts-py3.10) $ ./main.py --help
-   usage: main.py [-h] [--config-file CONFIG_FILE] [--output-dir OUTPUT_DIR] [-t TIMEOUT] [-v] [-s] [--tarball TARBALL] [--compile-timeout COMPILE_TIMEOUT] [--test-cases TEST_CASES] [--re-run RE_RUN]
+   usage: main.py [-h] [--config-file CONFIG_FILE] [--output-dir OUTPUT_DIR] [-t TIMEOUT] [-v] [-s] [--tarball TARBALL] [--compile-timeout COMPILE_TIMEOUT] [--test-suite TEST_SUITE [TEST_CASES ...]] [--re-run RE_RUN]
 
    Run DPDK test suites. All options may be specified with the environment variables provided in brackets. Command line arguments have higher priority.
 
    options:
    -h, --help            show this help message and exit
    --config-file CONFIG_FILE
-                         [DTS_CFG_FILE] configuration file that describes the test cases, SUTs and targets. (default: ./conf.yaml)
+                         [DTS_CFG_FILE] The configuration file that describes the test cases, SUTs and targets. (default: ./conf.yaml)
    --output-dir OUTPUT_DIR, --output OUTPUT_DIR
                          [DTS_OUTPUT_DIR] Output directory where DTS logs and results are saved. (default: output)
    -t TIMEOUT, --timeout TIMEOUT
                          [DTS_TIMEOUT] The default timeout for all DTS operations except for compiling DPDK. (default: 15)
    -v, --verbose         [DTS_VERBOSE] Specify to enable verbose output, logging all messages to the console. (default: False)
-   -s, --skip-setup      [DTS_SKIP_SETUP] Specify to skip all setup steps on SUT and TG nodes. (default: None)
+   -s, --skip-setup      [DTS_SKIP_SETUP] Specify to skip all setup steps on SUT and TG nodes. (default: False)
    --tarball TARBALL, --snapshot TARBALL, --git-ref TARBALL
                          [DTS_DPDK_TARBALL] Path to DPDK source code tarball or a git commit ID, tag ID or tree ID to test. To test local changes, first commit them, then use the commit ID with this option. (default: dpdk.tar.xz)
    --compile-timeout COMPILE_TIMEOUT
                          [DTS_COMPILE_TIMEOUT] The timeout for compiling DPDK. (default: 1200)
-   --test-cases TEST_CASES
-                         [DTS_TESTCASES] Comma-separated list of test cases to execute. Unknown test cases will be silently ignored. (default: )
+   --test-suite TEST_SUITE [TEST_CASES ...]
+                         [DTS_TEST_SUITES] A list containing a test suite with test cases. The first parameter is the test suite name, and the rest are test case names, which are optional. May be specified multiple times. To specify multiple test suites in the environment
+                         variable, join the lists with a comma. Examples: --test-suite suite case case --test-suite suite case ... | DTS_TEST_SUITES='suite case case, suite case, ...' | --test-suite suite --test-suite suite case ... | DTS_TEST_SUITES='suite, suite case, ...'
+                         (default: [])
    --re-run RE_RUN, --re_run RE_RUN
-                         [DTS_RERUN] Re-run each test case the specified number of times if a test failure occurs (default: 0)
+                         [DTS_RERUN] Re-run each test case the specified number of times if a test failure occurs. (default: 0)
 
 
 The brackets contain the names of environment variables that set the same thing.
diff --git a/dts/framework/config/__init__.py b/dts/framework/config/__init__.py
index c6a93b3b89..4cb5c74059 100644
--- a/dts/framework/config/__init__.py
+++ b/dts/framework/config/__init__.py
@@ -35,9 +35,9 @@
 
 import json
 import os.path
-import pathlib
 from dataclasses import dataclass, fields
 from enum import auto, unique
+from pathlib import Path
 from typing import Union
 
 import warlock  # type: ignore[import]
@@ -53,7 +53,6 @@
     TrafficGeneratorConfigDict,
 )
 from framework.exception import ConfigurationError
-from framework.settings import SETTINGS
 from framework.utils import StrEnum
 
 
@@ -571,7 +570,7 @@ def from_dict(d: ConfigurationDict) -> "Configuration":
         return Configuration(executions=executions)
 
 
-def load_config() -> Configuration:
+def load_config(config_file_path: Path) -> Configuration:
     """Load DTS test run configuration from a file.
 
     Load the YAML test run configuration file
@@ -581,13 +580,16 @@ def load_config() -> Configuration:
     The YAML test run configuration file is specified in the :option:`--config-file` command line
     argument or the :envvar:`DTS_CFG_FILE` environment variable.
 
+    Args:
+        config_file_path: The path to the YAML test run configuration file.
+
     Returns:
         The parsed test run configuration.
     """
-    with open(SETTINGS.config_file_path, "r") as f:
+    with open(config_file_path, "r") as f:
         config_data = yaml.safe_load(f)
 
-    schema_path = os.path.join(pathlib.Path(__file__).parent.resolve(), "conf_yaml_schema.json")
+    schema_path = os.path.join(Path(__file__).parent.resolve(), "conf_yaml_schema.json")
 
     with open(schema_path, "r") as f:
         schema = json.load(f)
diff --git a/dts/framework/runner.py b/dts/framework/runner.py
index af927b11a9..cabff0a7b2 100644
--- a/dts/framework/runner.py
+++ b/dts/framework/runner.py
@@ -24,7 +24,7 @@
 import sys
 from pathlib import Path
 from types import MethodType
-from typing import Iterable
+from typing import Iterable, Sequence
 
 from .config import (
     BuildTargetConfiguration,
@@ -83,7 +83,7 @@ class DTSRunner:
 
     def __init__(self):
         """Initialize the instance with configuration, logger, result and string constants."""
-        self._configuration = load_config()
+        self._configuration = load_config(SETTINGS.config_file_path)
         self._logger = get_dts_logger()
         if not os.path.exists(SETTINGS.output_dir):
             os.makedirs(SETTINGS.output_dir)
@@ -129,7 +129,7 @@ def run(self):
             #. Execution teardown
 
         The test cases are filtered according to the specification in the test run configuration and
-        the :option:`--test-cases` command line argument or
+        the :option:`--test-suite` command line argument or
         the :envvar:`DTS_TESTCASES` environment variable.
         """
         sut_nodes: dict[str, SutNode] = {}
@@ -147,7 +147,9 @@ def run(self):
                 )
                 execution_result = self._result.add_execution(execution)
                 # we don't want to modify the original config, so create a copy
-                execution_test_suites = list(execution.test_suites)
+                execution_test_suites = list(
+                    SETTINGS.test_suites if SETTINGS.test_suites else execution.test_suites
+                )
                 if not execution.skip_smoke_tests:
                     execution_test_suites[:0] = [TestSuiteConfig.from_dict("smoke_tests")]
                 try:
@@ -226,7 +228,7 @@ def _get_test_suites_with_cases(
             test_suite_class = self._get_test_suite_class(test_suite_config.test_suite)
             test_cases = []
             func_test_cases, perf_test_cases = self._filter_test_cases(
-                test_suite_class, set(test_suite_config.test_cases + SETTINGS.test_cases)
+                test_suite_class, test_suite_config.test_cases
             )
             if func:
                 test_cases.extend(func_test_cases)
@@ -301,7 +303,7 @@ def is_test_suite(object) -> bool:
         )
 
     def _filter_test_cases(
-        self, test_suite_class: type[TestSuite], test_cases_to_run: set[str]
+        self, test_suite_class: type[TestSuite], test_cases_to_run: Sequence[str]
     ) -> tuple[list[MethodType], list[MethodType]]:
         """Filter `test_cases_to_run` from `test_suite_class`.
 
@@ -330,7 +332,9 @@ def _filter_test_cases(
                 (name, method) for name, method in name_method_tuples if name in test_cases_to_run
             ]
             if len(name_method_tuples) < len(test_cases_to_run):
-                missing_test_cases = test_cases_to_run - {name for name, _ in name_method_tuples}
+                missing_test_cases = set(test_cases_to_run) - {
+                    name for name, _ in name_method_tuples
+                }
                 raise ConfigurationError(
                     f"Test cases {missing_test_cases} not found among methods "
                     f"of {test_suite_class.__name__}."
diff --git a/dts/framework/settings.py b/dts/framework/settings.py
index 2b8bfbe0ed..688e8679a7 100644
--- a/dts/framework/settings.py
+++ b/dts/framework/settings.py
@@ -48,10 +48,11 @@
 
     The path to a DPDK tarball, git commit ID, tag ID or tree ID to test.
 
-.. option:: --test-cases
-.. envvar:: DTS_TESTCASES
+.. option:: --test-suite
+.. envvar:: DTS_TEST_SUITES
 
-    A comma-separated list of test cases to execute. Unknown test cases will be silently ignored.
+        A test suite with test cases which may be specified multiple times.
+        In the environment variable, the suites are joined with a comma.
 
 .. option:: --re-run, --re_run
 .. envvar:: DTS_RERUN
@@ -71,83 +72,13 @@
 
 import argparse
 import os
-from collections.abc import Callable, Iterable, Sequence
 from dataclasses import dataclass, field
 from pathlib import Path
-from typing import Any, TypeVar
+from typing import Any
 
+from .config import TestSuiteConfig
 from .utils import DPDKGitTarball
 
-_T = TypeVar("_T")
-
-
-def _env_arg(env_var: str) -> Any:
-    """A helper method augmenting the argparse Action with environment variables.
-
-    If the supplied environment variable is defined, then the default value
-    of the argument is modified. This satisfies the priority order of
-    command line argument > environment variable > default value.
-
-    Arguments with no values (flags) should be defined using the const keyword argument
-    (True or False). When the argument is specified, it will be set to const, if not specified,
-    the default will be stored (possibly modified by the corresponding environment variable).
-
-    Other arguments work the same as default argparse arguments, that is using
-    the default 'store' action.
-
-    Returns:
-          The modified argparse.Action.
-    """
-
-    class _EnvironmentArgument(argparse.Action):
-        def __init__(
-            self,
-            option_strings: Sequence[str],
-            dest: str,
-            nargs: str | int | None = None,
-            const: bool | None = None,
-            default: Any = None,
-            type: Callable[[str], _T | argparse.FileType | None] = None,
-            choices: Iterable[_T] | None = None,
-            required: bool = False,
-            help: str | None = None,
-            metavar: str | tuple[str, ...] | None = None,
-        ) -> None:
-            env_var_value = os.environ.get(env_var)
-            default = env_var_value or default
-            if const is not None:
-                nargs = 0
-                default = const if env_var_value else default
-                type = None
-                choices = None
-                metavar = None
-            super(_EnvironmentArgument, self).__init__(
-                option_strings,
-                dest,
-                nargs=nargs,
-                const=const,
-                default=default,
-                type=type,
-                choices=choices,
-                required=required,
-                help=help,
-                metavar=metavar,
-            )
-
-        def __call__(
-            self,
-            parser: argparse.ArgumentParser,
-            namespace: argparse.Namespace,
-            values: Any,
-            option_string: str = None,
-        ) -> None:
-            if self.const is not None:
-                setattr(namespace, self.dest, self.const)
-            else:
-                setattr(namespace, self.dest, values)
-
-    return _EnvironmentArgument
-
 
 @dataclass(slots=True)
 class Settings:
@@ -171,7 +102,7 @@ class Settings:
     #:
     compile_timeout: float = 1200
     #:
-    test_cases: list[str] = field(default_factory=list)
+    test_suites: list[TestSuiteConfig] = field(default_factory=list)
     #:
     re_run: int = 0
 
@@ -180,6 +111,31 @@ class Settings:
 
 
 def _get_parser() -> argparse.ArgumentParser:
+    """Create the argument parser for DTS.
+
+    Command line options take precedence over environment variables, which in turn take precedence
+    over default values.
+
+    Returns:
+        argparse.ArgumentParser: The configured argument parser with defined options.
+    """
+
+    def env_arg(env_var: str, default: Any) -> Any:
+        """A helper function augmenting the argparse with environment variables.
+
+        If the supplied environment variable is defined, then the default value
+        of the argument is modified. This satisfies the priority order of
+        command line argument > environment variable > default value.
+
+        Args:
+            env_var: Environment variable name.
+            default: Default value.
+
+        Returns:
+            Environment variable or default value.
+        """
+        return os.environ.get(env_var) or default
+
     parser = argparse.ArgumentParser(
         description="Run DPDK test suites. All options may be specified with the environment "
         "variables provided in brackets. Command line arguments have higher priority.",
@@ -188,25 +144,23 @@ def _get_parser() -> argparse.ArgumentParser:
 
     parser.add_argument(
         "--config-file",
-        action=_env_arg("DTS_CFG_FILE"),
-        default=SETTINGS.config_file_path,
+        default=env_arg("DTS_CFG_FILE", SETTINGS.config_file_path),
         type=Path,
-        help="[DTS_CFG_FILE] configuration file that describes the test cases, SUTs and targets.",
+        help="[DTS_CFG_FILE] The configuration file that describes the test cases, "
+        "SUTs and targets.",
     )
 
     parser.add_argument(
         "--output-dir",
         "--output",
-        action=_env_arg("DTS_OUTPUT_DIR"),
-        default=SETTINGS.output_dir,
+        default=env_arg("DTS_OUTPUT_DIR", SETTINGS.output_dir),
         help="[DTS_OUTPUT_DIR] Output directory where DTS logs and results are saved.",
     )
 
     parser.add_argument(
         "-t",
         "--timeout",
-        action=_env_arg("DTS_TIMEOUT"),
-        default=SETTINGS.timeout,
+        default=env_arg("DTS_TIMEOUT", SETTINGS.timeout),
         type=float,
         help="[DTS_TIMEOUT] The default timeout for all DTS operations except for compiling DPDK.",
     )
@@ -214,9 +168,8 @@ def _get_parser() -> argparse.ArgumentParser:
     parser.add_argument(
         "-v",
         "--verbose",
-        action=_env_arg("DTS_VERBOSE"),
-        default=SETTINGS.verbose,
-        const=True,
+        action="store_true",
+        default=env_arg("DTS_VERBOSE", SETTINGS.verbose),
         help="[DTS_VERBOSE] Specify to enable verbose output, logging all messages "
         "to the console.",
     )
@@ -224,8 +177,8 @@ def _get_parser() -> argparse.ArgumentParser:
     parser.add_argument(
         "-s",
         "--skip-setup",
-        action=_env_arg("DTS_SKIP_SETUP"),
-        const=True,
+        action="store_true",
+        default=env_arg("DTS_SKIP_SETUP", SETTINGS.skip_setup),
         help="[DTS_SKIP_SETUP] Specify to skip all setup steps on SUT and TG nodes.",
     )
 
@@ -233,8 +186,7 @@ def _get_parser() -> argparse.ArgumentParser:
         "--tarball",
         "--snapshot",
         "--git-ref",
-        action=_env_arg("DTS_DPDK_TARBALL"),
-        default=SETTINGS.dpdk_tarball_path,
+        default=env_arg("DTS_DPDK_TARBALL", SETTINGS.dpdk_tarball_path),
         type=Path,
         help="[DTS_DPDK_TARBALL] Path to DPDK source code tarball or a git commit ID, "
         "tag ID or tree ID to test. To test local changes, first commit them, "
@@ -243,36 +195,71 @@ def _get_parser() -> argparse.ArgumentParser:
 
     parser.add_argument(
         "--compile-timeout",
-        action=_env_arg("DTS_COMPILE_TIMEOUT"),
-        default=SETTINGS.compile_timeout,
+        default=env_arg("DTS_COMPILE_TIMEOUT", SETTINGS.compile_timeout),
         type=float,
         help="[DTS_COMPILE_TIMEOUT] The timeout for compiling DPDK.",
     )
 
     parser.add_argument(
-        "--test-cases",
-        action=_env_arg("DTS_TESTCASES"),
-        default="",
-        help="[DTS_TESTCASES] Comma-separated list of test cases to execute.",
+        "--test-suite",
+        action="append",
+        nargs="+",
+        metavar=("TEST_SUITE", "TEST_CASES"),
+        default=env_arg("DTS_TEST_SUITES", SETTINGS.test_suites),
+        help="[DTS_TEST_SUITES] A list containing a test suite with test cases. "
+        "The first parameter is the test suite name, and the rest are test case names, "
+        "which are optional. May be specified multiple times. To specify multiple test suites in "
+        "the environment variable, join the lists with a comma. "
+        "Examples: "
+        "--test-suite suite case case --test-suite suite case ... | "
+        "DTS_TEST_SUITES='suite case case, suite case, ...' | "
+        "--test-suite suite --test-suite suite case ... | "
+        "DTS_TEST_SUITES='suite, suite case, ...'",
     )
 
     parser.add_argument(
         "--re-run",
         "--re_run",
-        action=_env_arg("DTS_RERUN"),
-        default=SETTINGS.re_run,
+        default=env_arg("DTS_RERUN", SETTINGS.re_run),
         type=int,
         help="[DTS_RERUN] Re-run each test case the specified number of times "
-        "if a test failure occurs",
+        "if a test failure occurs.",
     )
 
     return parser
 
 
+def _process_test_suites(args: str | list[list[str]]) -> list[TestSuiteConfig]:
+    """Process the given argument to a list of :class:`TestSuiteConfig` to execute.
+
+    Args:
+        args: The arguments to process. The args is a string from an environment variable
+              or a list of from the user input containing tests suites with tests cases,
+              each of which is a list of [test_suite, test_case, test_case, ...].
+
+    Returns:
+        A list of test suite configurations to execute.
+    """
+    if isinstance(args, str):
+        # Environment variable in the form of "suite case case, suite case, suite, ..."
+        args = [suite_with_cases.split() for suite_with_cases in args.split(",")]
+
+    test_suites_to_run = []
+    for suite_with_cases in args:
+        test_suites_to_run.append(
+            TestSuiteConfig(test_suite=suite_with_cases[0], test_cases=suite_with_cases[1:])
+        )
+
+    return test_suites_to_run
+
+
 def get_settings() -> Settings:
     """Create new settings with inputs from the user.
 
     The inputs are taken from the command line and from environment variables.
+
+    Returns:
+        The new settings object.
     """
     parsed_args = _get_parser().parse_args()
     return Settings(
@@ -287,6 +274,6 @@ def get_settings() -> Settings:
             else Path(parsed_args.tarball)
         ),
         compile_timeout=parsed_args.compile_timeout,
-        test_cases=(parsed_args.test_cases.split(",") if parsed_args.test_cases else []),
+        test_suites=_process_test_suites(parsed_args.test_suite),
         re_run=parsed_args.re_run,
     )
diff --git a/dts/framework/test_suite.py b/dts/framework/test_suite.py
index 365f80e21a..1957ea7328 100644
--- a/dts/framework/test_suite.py
+++ b/dts/framework/test_suite.py
@@ -40,7 +40,7 @@ class TestSuite(object):
     and functional test cases (all other test cases).
 
     By default, all test cases will be executed. A list of testcase names may be specified
-    in the YAML test run configuration file and in the :option:`--test-cases` command line argument
+    in the YAML test run configuration file and in the :option:`--test-suite` command line argument
     or in the :envvar:`DTS_TESTCASES` environment variable to filter which test cases to run.
     The union of both lists will be used. Any unknown test cases from the latter lists
     will be silently ignored.
-- 
2.34.1


^ permalink raw reply	[flat|nested] 28+ messages in thread

end of thread, other threads:[~2024-02-23  7:56 UTC | newest]

Thread overview: 28+ messages (download: mbox.gz / follow: Atom feed)
-- links below jump to the message on this page --
2023-12-20 10:33 [RFC PATCH v1 0/5] test case blocking and logging Juraj Linkeš
2023-12-20 10:33 ` [RFC PATCH v1 1/5] dts: convert dts.py methods to class Juraj Linkeš
2023-12-20 10:33 ` [RFC PATCH v1 2/5] dts: move test suite execution logic to DTSRunner Juraj Linkeš
2023-12-20 10:33 ` [RFC PATCH v1 3/5] dts: process test suites at the beginning of run Juraj Linkeš
2023-12-20 10:33 ` [RFC PATCH v1 4/5] dts: block all testcases when earlier setup fails Juraj Linkeš
2023-12-20 10:33 ` [RFC PATCH v1 5/5] dts: refactor logging configuration Juraj Linkeš
2024-01-08 18:47 ` [RFC PATCH v1 0/5] test case blocking and logging Jeremy Spewock
2024-02-06 14:57 ` [PATCH v2 0/7] " Juraj Linkeš
2024-02-06 14:57   ` [PATCH v2 1/7] dts: convert dts.py methods to class Juraj Linkeš
2024-02-06 14:57   ` [PATCH v2 2/7] dts: move test suite execution logic to DTSRunner Juraj Linkeš
2024-02-06 14:57   ` [PATCH v2 3/7] dts: filter test suites in executions Juraj Linkeš
2024-02-12 16:44     ` Jeremy Spewock
2024-02-14  9:55       ` Juraj Linkeš
2024-02-06 14:57   ` [PATCH v2 4/7] dts: reorganize test result Juraj Linkeš
2024-02-06 14:57   ` [PATCH v2 5/7] dts: block all test cases when earlier setup fails Juraj Linkeš
2024-02-06 14:57   ` [PATCH v2 6/7] dts: refactor logging configuration Juraj Linkeš
2024-02-12 16:45     ` Jeremy Spewock
2024-02-14  7:49       ` Juraj Linkeš
2024-02-14 16:51         ` Jeremy Spewock
2024-02-06 14:57   ` [PATCH v2 7/7] dts: improve test suite and case filtering Juraj Linkeš
2024-02-23  7:54 ` [PATCH v3 0/7] test case blocking and logging Juraj Linkeš
2024-02-23  7:54   ` [PATCH v3 1/7] dts: convert dts.py methods to class Juraj Linkeš
2024-02-23  7:54   ` [PATCH v3 2/7] dts: move test suite execution logic to DTSRunner Juraj Linkeš
2024-02-23  7:54   ` [PATCH v3 3/7] dts: filter test suites in executions Juraj Linkeš
2024-02-23  7:54   ` [PATCH v3 4/7] dts: reorganize test result Juraj Linkeš
2024-02-23  7:55   ` [PATCH v3 5/7] dts: block all test cases when earlier setup fails Juraj Linkeš
2024-02-23  7:55   ` [PATCH v3 6/7] dts: refactor logging configuration Juraj Linkeš
2024-02-23  7:55   ` [PATCH v3 7/7] dts: improve test suite and case filtering Juraj Linkeš

This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox;
as well as URLs for NNTP newsgroup(s).