[v6,2/9] dts: add TestSuiteSpec class and discovery

Message ID 20241108114006.64595-3-luca.vizzarro@arm.com (mailing list archive)
State Accepted, archived
Delegated to: Paul Szczepanek
Headers
Series dts: Pydantic configuration |

Checks

Context Check Description
ci/checkpatch success coding style OK

Commit Message

Luca Vizzarro Nov. 8, 2024, 11:39 a.m. UTC
Currently there is a lack of a definition which identifies all the test
suites available to test. This change intends to simplify the process to
discover all the test suites and identify them.

Signed-off-by: Luca Vizzarro <luca.vizzarro@arm.com>
Reviewed-by: Paul Szczepanek <paul.szczepanek@arm.com>
Reviewed-by: Nicholas Pratte <npratte@iol.unh.edu>
Reviewed-by: Patrick Robb <probb@iol.unh.edu>
---
 dts/framework/runner.py                   |   2 +-
 dts/framework/test_suite.py               | 190 +++++++++++++++++++---
 dts/framework/testbed_model/capability.py |  12 +-
 dts/framework/utils.py                    |   5 +
 4 files changed, 182 insertions(+), 27 deletions(-)
  

Comments

Ali Alnubani Nov. 20, 2024, 8:48 a.m. UTC | #1
> -----Original Message-----
> From: Luca Vizzarro <luca.vizzarro@arm.com>
> Sent: Friday, November 8, 2024 1:40 PM
> To: dev@dpdk.org
> Cc: Paul Szczepanek <paul.szczepanek@arm.com>; Patrick Robb
> <probb@iol.unh.edu>; Luca Vizzarro <luca.vizzarro@arm.com>; Nicholas Pratte
> <npratte@iol.unh.edu>
> Subject: [PATCH v6 2/9] dts: add TestSuiteSpec class and discovery
> 
> Currently there is a lack of a definition which identifies all the test
> suites available to test. This change intends to simplify the process to
> discover all the test suites and identify them.
> 
> Signed-off-by: Luca Vizzarro <luca.vizzarro@arm.com>
> Reviewed-by: Paul Szczepanek <paul.szczepanek@arm.com>
> Reviewed-by: Nicholas Pratte <npratte@iol.unh.edu>
> Reviewed-by: Patrick Robb <probb@iol.unh.edu>
> ---

Hello,

Documentation seems to be failing to build on top of this patch on Fedora 40:

"""
$ ninja-build -C build doc
[..]
Warning, treated as error:
autodoc: failed to import module 'runner' from module 'framework'; the following exception was raised:
Traceback (most recent call last):
  File "/usr/lib/python3.12/site-packages/sphinx/ext/autodoc/importer.py", line 69, in import_module
    return importlib.import_module(modname)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib64/python3.12/importlib/__init__.py", line 90, in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "<frozen importlib._bootstrap>", line 1387, in _gcd_import
  File "<frozen importlib._bootstrap>", line 1360, in _find_and_load
  File "<frozen importlib._bootstrap>", line 1331, in _find_and_load_unlocked
  File "<frozen importlib._bootstrap>", line 935, in _load_unlocked
  File "<frozen importlib._bootstrap_external>", line 995, in exec_module
  File "<frozen importlib._bootstrap>", line 488, in _call_with_frames_removed
  File "dts/framework/runner.py", line 42, in <module>
    from .test_result import (
  File "dts/framework/test_result.py", line 37, in <module>
    from .test_suite import TestCase, TestSuite
  File "dts/framework/test_suite.py", line 715, in <module>
    AVAILABLE_TEST_SUITES: list[TestSuiteSpec] = TestSuiteSpec.discover_all()
                                                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "dts/framework/test_suite.py", line 707, in discover_all
    if test_suite.class_obj:
       ^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib64/python3.12/functools.py", line 993, in __get__
    val = self.func(instance)
          ^^^^^^^^^^^^^^^^^^^
  File "dts/framework/test_suite.py", line 658, in class_obj
    for class_name, class_obj in inspect.getmembers(self.module, is_test_suite):
                                                    ^^^^^^^^^^^
  File "/usr/lib64/python3.12/functools.py", line 993, in __get__
    val = self.func(instance)
          ^^^^^^^^^^^^^^^^^^^
  File "dts/framework/test_suite.py", line 629, in module
    return import_module(f"{self.TEST_SUITES_PACKAGE_NAME}.{self.module_name}")
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib64/python3.12/importlib/__init__.py", line 90, in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "dts/tests/TestSuite_hello_world.py", line 20, in <module>
    @requires(topology_type=TopologyType.no_link)
     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "dts/framework/testbed_model/capability.py", line 479, in add_required_capability
    topology_capability = TopologyCapability.get_unique(topology_type)
                          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "dts/framework/testbed_model/capability.py", line 331, in get_unique
    if topology_type.name not in cls._unique_capabilities:
       ^^^^^^^^^^^^^^^^^^
AttributeError: 'int' object has no attribute 'name'
"""

Regards,
Ali
  
Luca Vizzarro Nov. 20, 2024, 2:04 p.m. UTC | #2
Hi Ali,

this warning has been fixed by the following patch:

   dts: fix custom enum behaviour with docs

so at the top of the tree it shouldn't present itself. If it's a 
critical problem, the fix can be rearranged to appear before.

Luca
  
Thomas Monjalon Nov. 20, 2024, 2:35 p.m. UTC | #3
20/11/2024 15:04, Luca Vizzarro:
> Hi Ali,
> 
> this warning has been fixed by the following patch:
> 
>    dts: fix custom enum behaviour with docs
> 
> so at the top of the tree it shouldn't present itself. If it's a 
> critical problem, the fix can be rearranged to appear before.

Too late, the series was merged already.
In future, please sort your patches correctly so there is no intermediate issue.
  

Patch

diff --git a/dts/framework/runner.py b/dts/framework/runner.py
index 8bbe698eaf..195622c653 100644
--- a/dts/framework/runner.py
+++ b/dts/framework/runner.py
@@ -225,7 +225,7 @@  def _get_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: list[type[TestCase]] = []
-            func_test_cases, perf_test_cases = test_suite_class.get_test_cases(
+            func_test_cases, perf_test_cases = test_suite_class.filter_test_cases(
                 test_suite_config.test_cases
             )
             if func:
diff --git a/dts/framework/test_suite.py b/dts/framework/test_suite.py
index cbe3b30ffc..fb5d646ce3 100644
--- a/dts/framework/test_suite.py
+++ b/dts/framework/test_suite.py
@@ -1,6 +1,7 @@ 
 # SPDX-License-Identifier: BSD-3-Clause
 # Copyright(c) 2010-2014 Intel Corporation
 # Copyright(c) 2023 PANTHEON.tech s.r.o.
+# Copyright(c) 2024 Arm Limited
 
 """Features common to all test suites.
 
@@ -16,13 +17,19 @@ 
 import inspect
 from collections import Counter
 from collections.abc import Callable, Sequence
+from dataclasses import dataclass
 from enum import Enum, auto
+from functools import cached_property
+from importlib import import_module
 from ipaddress import IPv4Interface, IPv6Interface, ip_interface
+from pkgutil import iter_modules
+from types import ModuleType
 from typing import ClassVar, Protocol, TypeVar, Union, cast
 
 from scapy.layers.inet import IP  # type: ignore[import-untyped]
 from scapy.layers.l2 import Ether  # type: ignore[import-untyped]
 from scapy.packet import Packet, Padding, raw  # type: ignore[import-untyped]
+from typing_extensions import Self
 
 from framework.testbed_model.capability import TestProtocol
 from framework.testbed_model.port import Port
@@ -33,9 +40,9 @@ 
     PacketFilteringConfig,
 )
 
-from .exception import ConfigurationError, TestCaseVerifyError
+from .exception import ConfigurationError, InternalError, TestCaseVerifyError
 from .logger import DTSLogger, get_dts_logger
-from .utils import get_packet_summaries
+from .utils import get_packet_summaries, to_pascal_case
 
 
 class TestSuite(TestProtocol):
@@ -112,10 +119,24 @@  def __init__(
         self._tg_ip_address_ingress = ip_interface("192.168.101.3/24")
 
     @classmethod
-    def get_test_cases(
+    def get_test_cases(cls) -> list[type["TestCase"]]:
+        """A list of all the available test cases."""
+
+        def is_test_case(function: Callable) -> bool:
+            if inspect.isfunction(function):
+                # TestCase is not used at runtime, so we can't use isinstance() with `function`.
+                # But function.test_type exists.
+                if hasattr(function, "test_type"):
+                    return isinstance(function.test_type, TestCaseType)
+            return False
+
+        return [test_case for _, test_case in inspect.getmembers(cls, is_test_case)]
+
+    @classmethod
+    def filter_test_cases(
         cls, test_case_sublist: Sequence[str] | None = None
     ) -> tuple[set[type["TestCase"]], set[type["TestCase"]]]:
-        """Filter `test_case_subset` from this class.
+        """Filter `test_case_sublist` from this class.
 
         Test cases are regular (or bound) methods decorated with :func:`func_test`
         or :func:`perf_test`.
@@ -129,17 +150,8 @@  def get_test_cases(
             as methods are bound to instances and this method only has access to the class.
 
         Raises:
-            ConfigurationError: If a test case from `test_case_subset` is not found.
+            ConfigurationError: If a test case from `test_case_sublist` is not found.
         """
-
-        def is_test_case(function: Callable) -> bool:
-            if inspect.isfunction(function):
-                # TestCase is not used at runtime, so we can't use isinstance() with `function`.
-                # But function.test_type exists.
-                if hasattr(function, "test_type"):
-                    return isinstance(function.test_type, TestCaseType)
-            return False
-
         if test_case_sublist is None:
             test_case_sublist = []
 
@@ -149,22 +161,22 @@  def is_test_case(function: Callable) -> bool:
         func_test_cases = set()
         perf_test_cases = set()
 
-        for test_case_name, test_case_function in inspect.getmembers(cls, is_test_case):
-            if test_case_name in test_case_sublist_copy:
+        for test_case in cls.get_test_cases():
+            if test_case.name in test_case_sublist_copy:
                 # if test_case_sublist_copy is non-empty, remove the found test case
                 # so that we can look at the remainder at the end
-                test_case_sublist_copy.remove(test_case_name)
+                test_case_sublist_copy.remove(test_case.name)
             elif test_case_sublist:
                 # the original list not being empty means we're filtering test cases
-                # since we didn't remove test_case_name in the previous branch,
+                # since we didn't remove test_case.name in the previous branch,
                 # it doesn't match the filter and we don't want to remove it
                 continue
 
-            match test_case_function.test_type:
+            match test_case.test_type:
                 case TestCaseType.PERFORMANCE:
-                    perf_test_cases.add(test_case_function)
+                    perf_test_cases.add(test_case)
                 case TestCaseType.FUNCTIONAL:
-                    func_test_cases.add(test_case_function)
+                    func_test_cases.add(test_case)
 
         if test_case_sublist_copy:
             raise ConfigurationError(
@@ -536,6 +548,8 @@  class TestCase(TestProtocol, Protocol[TestSuiteMethodType]):
     test case function to :class:`TestCase` and sets common variables.
     """
 
+    #:
+    name: ClassVar[str]
     #:
     test_type: ClassVar[TestCaseType]
     #: necessary for mypy so that it can treat this class as the function it's shadowing
@@ -560,6 +574,7 @@  def make_decorator(
 
         def _decorator(func: TestSuiteMethodType) -> type[TestCase]:
             test_case = cast(type[TestCase], func)
+            test_case.name = func.__name__
             test_case.skip = cls.skip
             test_case.skip_reason = cls.skip_reason
             test_case.required_capabilities = set()
@@ -575,3 +590,136 @@  def _decorator(func: TestSuiteMethodType) -> type[TestCase]:
 func_test: Callable = TestCase.make_decorator(TestCaseType.FUNCTIONAL)
 #: The decorator for performance test cases.
 perf_test: Callable = TestCase.make_decorator(TestCaseType.PERFORMANCE)
+
+
+@dataclass
+class TestSuiteSpec:
+    """A class defining the specification of a test suite.
+
+    Apart from defining all the specs of a test suite, a helper function :meth:`discover_all` is
+    provided to automatically discover all the available test suites.
+
+    Attributes:
+        module_name: The name of the test suite's module.
+    """
+
+    #:
+    TEST_SUITES_PACKAGE_NAME = "tests"
+    #:
+    TEST_SUITE_MODULE_PREFIX = "TestSuite_"
+    #:
+    TEST_SUITE_CLASS_PREFIX = "Test"
+    #:
+    TEST_CASE_METHOD_PREFIX = "test_"
+    #:
+    FUNC_TEST_CASE_REGEX = r"test_(?!perf_)"
+    #:
+    PERF_TEST_CASE_REGEX = r"test_perf_"
+
+    module_name: str
+
+    @cached_property
+    def name(self) -> str:
+        """The name of the test suite's module."""
+        return self.module_name[len(self.TEST_SUITE_MODULE_PREFIX) :]
+
+    @cached_property
+    def module(self) -> ModuleType:
+        """A reference to the test suite's module."""
+        return import_module(f"{self.TEST_SUITES_PACKAGE_NAME}.{self.module_name}")
+
+    @cached_property
+    def class_name(self) -> str:
+        """The name of the test suite's class."""
+        return f"{self.TEST_SUITE_CLASS_PREFIX}{to_pascal_case(self.name)}"
+
+    @cached_property
+    def class_obj(self) -> type[TestSuite]:
+        """A reference to the test suite's class."""
+
+        def is_test_suite(obj) -> bool:
+            """Check whether `obj` is a :class:`TestSuite`.
+
+            The `obj` is a subclass of :class:`TestSuite`, but not :class:`TestSuite` itself.
+
+            Args:
+                obj: The object to be checked.
+
+            Returns:
+                :data:`True` if `obj` is a subclass of `TestSuite`.
+            """
+            try:
+                if issubclass(obj, TestSuite) and obj is not TestSuite:
+                    return True
+            except TypeError:
+                return False
+            return False
+
+        for class_name, class_obj in inspect.getmembers(self.module, is_test_suite):
+            if class_name == self.class_name:
+                return class_obj
+
+        raise InternalError(
+            f"Expected class {self.class_name} not found in module {self.module_name}."
+        )
+
+    @classmethod
+    def discover_all(
+        cls, package_name: str | None = None, module_prefix: str | None = None
+    ) -> list[Self]:
+        """Discover all the test suites.
+
+        The test suites are discovered in the provided `package_name`. The full module name,
+        expected under that package, is prefixed with `module_prefix`.
+        The module name is a standard filename with words separated with underscores.
+        For each module found, search for a :class:`TestSuite` class which starts
+        with :attr:`~TestSuiteSpec.TEST_SUITE_CLASS_PREFIX`, continuing with the module name in
+        PascalCase.
+
+        The PascalCase convention applies to abbreviations, acronyms, initialisms and so on::
+
+            OS -> Os
+            TCP -> Tcp
+
+        Args:
+            package_name: The name of the package where to find the test suites. If :data:`None`,
+                the :attr:`~TestSuiteSpec.TEST_SUITES_PACKAGE_NAME` is used.
+            module_prefix: The name prefix defining the test suite module. If :data:`None`, the
+                :attr:`~TestSuiteSpec.TEST_SUITE_MODULE_PREFIX` constant is used.
+
+        Returns:
+            A list containing all the discovered test suites.
+        """
+        if package_name is None:
+            package_name = cls.TEST_SUITES_PACKAGE_NAME
+        if module_prefix is None:
+            module_prefix = cls.TEST_SUITE_MODULE_PREFIX
+
+        test_suites = []
+
+        test_suites_pkg = import_module(package_name)
+        for _, module_name, is_pkg in iter_modules(test_suites_pkg.__path__):
+            if not module_name.startswith(module_prefix) or is_pkg:
+                continue
+
+            test_suite = cls(module_name)
+            try:
+                if test_suite.class_obj:
+                    test_suites.append(test_suite)
+            except InternalError as err:
+                get_dts_logger().warning(err)
+
+        return test_suites
+
+
+AVAILABLE_TEST_SUITES: list[TestSuiteSpec] = TestSuiteSpec.discover_all()
+"""Constant to store all the available, discovered and imported test suites.
+
+The test suites should be gathered from this list to avoid importing more than once.
+"""
+
+
+def find_by_name(name: str) -> TestSuiteSpec | None:
+    """Find a requested test suite by name from the available ones."""
+    test_suites = filter(lambda t: t.name == name, AVAILABLE_TEST_SUITES)
+    return next(test_suites, None)
diff --git a/dts/framework/testbed_model/capability.py b/dts/framework/testbed_model/capability.py
index 2207957a7a..0d5f0e0b32 100644
--- a/dts/framework/testbed_model/capability.py
+++ b/dts/framework/testbed_model/capability.py
@@ -47,9 +47,9 @@  def test_scatter_mbuf_2048(self):
 
 import inspect
 from abc import ABC, abstractmethod
-from collections.abc import MutableSet, Sequence
+from collections.abc import MutableSet
 from dataclasses import dataclass
-from typing import Callable, ClassVar, Protocol
+from typing import TYPE_CHECKING, Callable, ClassVar, Protocol
 
 from typing_extensions import Self
 
@@ -66,6 +66,9 @@  def test_scatter_mbuf_2048(self):
 from .sut_node import SutNode
 from .topology import Topology, TopologyType
 
+if TYPE_CHECKING:
+    from framework.test_suite import TestCase
+
 
 class Capability(ABC):
     """The base class for various capabilities.
@@ -354,8 +357,7 @@  def set_required(self, test_case_or_suite: type["TestProtocol"]) -> None:
         if inspect.isclass(test_case_or_suite):
             if self.topology_type is not TopologyType.default:
                 self.add_to_required(test_case_or_suite)
-                func_test_cases, perf_test_cases = test_case_or_suite.get_test_cases()
-                for test_case in func_test_cases | perf_test_cases:
+                for test_case in test_case_or_suite.get_test_cases():
                     if test_case.topology_type.topology_type is TopologyType.default:
                         # test case topology has not been set, use the one set by the test suite
                         self.add_to_required(test_case)
@@ -446,7 +448,7 @@  class TestProtocol(Protocol):
     required_capabilities: ClassVar[set[Capability]] = set()
 
     @classmethod
-    def get_test_cases(cls, test_case_sublist: Sequence[str] | None = None) -> tuple[set, set]:
+    def get_test_cases(cls) -> list[type["TestCase"]]:
         """Get test cases. Should be implemented by subclasses containing test cases.
 
         Raises:
diff --git a/dts/framework/utils.py b/dts/framework/utils.py
index 78a39e32c7..43e2592fce 100644
--- a/dts/framework/utils.py
+++ b/dts/framework/utils.py
@@ -303,3 +303,8 @@  class MultiInheritanceBaseClass:
     def __init__(self, *args, **kwargs) -> None:
         """Call the init method of :class:`object`."""
         super().__init__()
+
+
+def to_pascal_case(text: str) -> str:
+    """Convert `text` from snake_case to PascalCase."""
+    return "".join([seg.capitalize() for seg in text.split("_")])