xref: /dpdk/dts/framework/testbed_model/capability.py (revision c64af3c7a80ee76a771017b56487be1062c7ae1f)
1# SPDX-License-Identifier: BSD-3-Clause
2# Copyright(c) 2024 PANTHEON.tech s.r.o.
3
4"""Testbed capabilities.
5
6This module provides a protocol that defines the common attributes of test cases and suites
7and support for test environment capabilities.
8
9Many test cases are testing features not available on all hardware.
10On the other hand, some test cases or suites may not need the most complex topology available.
11
12The module allows developers to mark test cases or suites a requiring certain hardware capabilities
13or a particular topology with the :func:`requires` decorator.
14
15There are differences between hardware and topology capabilities:
16
17    * Hardware capabilities are assumed to not be required when not specified.
18    * However, some topology is always available, so each test case or suite is assigned
19      a default topology if no topology is specified in the decorator.
20
21The module also allows developers to mark test cases or suites as requiring certain
22hardware capabilities with the :func:`requires` decorator.
23
24Examples:
25    .. code:: python
26
27        from framework.test_suite import TestSuite, func_test
28        from framework.testbed_model.capability import TopologyType, requires
29        # The whole test suite (each test case within) doesn't require any links.
30        @requires(topology_type=TopologyType.no_link)
31        @func_test
32        class TestHelloWorld(TestSuite):
33            def hello_world_single_core(self):
34            ...
35
36    .. code:: python
37
38        from framework.test_suite import TestSuite, func_test
39        from framework.testbed_model.capability import NicCapability, requires
40        class TestPmdBufferScatter(TestSuite):
41            # only the test case requires the SCATTERED_RX_ENABLED capability
42            # other test cases may not require it
43            @requires(NicCapability.SCATTERED_RX_ENABLED)
44            @func_test
45            def test_scatter_mbuf_2048(self):
46"""
47
48import inspect
49from abc import ABC, abstractmethod
50from collections.abc import MutableSet
51from dataclasses import dataclass
52from typing import TYPE_CHECKING, Callable, ClassVar, Protocol
53
54from typing_extensions import Self
55
56from framework.exception import ConfigurationError
57from framework.logger import get_dts_logger
58from framework.remote_session.testpmd_shell import (
59    NicCapability,
60    TestPmdShell,
61    TestPmdShellCapabilityMethod,
62    TestPmdShellDecorator,
63    TestPmdShellMethod,
64)
65
66from .sut_node import SutNode
67from .topology import Topology, TopologyType
68
69if TYPE_CHECKING:
70    from framework.test_suite import TestCase
71
72
73class Capability(ABC):
74    """The base class for various capabilities.
75
76    The same capability should always be represented by the same object,
77    meaning the same capability required by different test cases or suites
78    should point to the same object.
79
80    Example:
81        ``test_case1`` and ``test_case2`` each require ``capability1``
82        and in both instances, ``capability1`` should point to the same capability object.
83
84    It is up to the subclasses how they implement this.
85
86    The instances are used in sets so they must be hashable.
87    """
88
89    #: A set storing the capabilities whose support should be checked.
90    capabilities_to_check: ClassVar[set[Self]] = set()
91
92    def register_to_check(self) -> Callable[[SutNode, "Topology"], set[Self]]:
93        """Register the capability to be checked for support.
94
95        Returns:
96            The callback function that checks the support of capabilities of the particular subclass
97            which should be called after all capabilities have been registered.
98        """
99        if not type(self).capabilities_to_check:
100            type(self).capabilities_to_check = set()
101        type(self).capabilities_to_check.add(self)
102        return type(self)._get_and_reset
103
104    def add_to_required(self, test_case_or_suite: type["TestProtocol"]) -> None:
105        """Add the capability instance to the required test case or suite's capabilities.
106
107        Args:
108            test_case_or_suite: The test case or suite among whose required capabilities
109                to add this instance.
110        """
111        if not test_case_or_suite.required_capabilities:
112            test_case_or_suite.required_capabilities = set()
113        self._preprocess_required(test_case_or_suite)
114        test_case_or_suite.required_capabilities.add(self)
115
116    def _preprocess_required(self, test_case_or_suite: type["TestProtocol"]) -> None:
117        """An optional method that modifies the required capabilities."""
118
119    @classmethod
120    def _get_and_reset(cls, sut_node: SutNode, topology: "Topology") -> set[Self]:
121        """The callback method to be called after all capabilities have been registered.
122
123        Not only does this method check the support of capabilities,
124        but it also reset the internal set of registered capabilities
125        so that the "register, then get support" workflow works in subsequent test runs.
126        """
127        supported_capabilities = cls.get_supported_capabilities(sut_node, topology)
128        cls.capabilities_to_check = set()
129        return supported_capabilities
130
131    @classmethod
132    @abstractmethod
133    def get_supported_capabilities(cls, sut_node: SutNode, topology: "Topology") -> set[Self]:
134        """Get the support status of each registered capability.
135
136        Each subclass must implement this method and return the subset of supported capabilities
137        of :attr:`capabilities_to_check`.
138
139        Args:
140            sut_node: The SUT node of the current test run.
141            topology: The topology of the current test run.
142
143        Returns:
144            The supported capabilities.
145        """
146
147    @abstractmethod
148    def __hash__(self) -> int:
149        """The subclasses must be hashable so that they can be stored in sets."""
150
151
152@dataclass
153class DecoratedNicCapability(Capability):
154    """A wrapper around :class:`~framework.remote_session.testpmd_shell.NicCapability`.
155
156    New instances should be created with the :meth:`create_unique` class method to ensure
157    there are no duplicate instances.
158
159    Attributes:
160        nic_capability: The NIC capability that defines each instance.
161        capability_fn: The capability retrieval function of `nic_capability`.
162        capability_decorator: The decorator function of `nic_capability`.
163            This function will wrap `capability_fn`.
164    """
165
166    nic_capability: NicCapability
167    capability_fn: TestPmdShellCapabilityMethod
168    capability_decorator: TestPmdShellDecorator | None
169    _unique_capabilities: ClassVar[dict[NicCapability, Self]] = {}
170
171    @classmethod
172    def get_unique(cls, nic_capability: NicCapability) -> "DecoratedNicCapability":
173        """Get the capability uniquely identified by `nic_capability`.
174
175        This is a factory method that implements a quasi-enum pattern.
176        The instances of this class are stored in an internal class variable,
177        `_unique_capabilities`.
178
179        If an instance identified by `nic_capability` doesn't exist,
180        it is created and added to `_unique_capabilities`.
181        If it exists, it is returned so that a new identical instance is not created.
182
183        Args:
184            nic_capability: The NIC capability.
185
186        Returns:
187            The capability uniquely identified by `nic_capability`.
188        """
189        decorator_fn = None
190        if isinstance(nic_capability.value, tuple):
191            capability_fn, decorator_fn = nic_capability.value
192        else:
193            capability_fn = nic_capability.value
194
195        if nic_capability not in cls._unique_capabilities:
196            cls._unique_capabilities[nic_capability] = cls(
197                nic_capability, capability_fn, decorator_fn
198            )
199        return cls._unique_capabilities[nic_capability]
200
201    @classmethod
202    def get_supported_capabilities(
203        cls, sut_node: SutNode, topology: "Topology"
204    ) -> set["DecoratedNicCapability"]:
205        """Overrides :meth:`~Capability.get_supported_capabilities`.
206
207        The capabilities are first sorted by decorators, then reduced into a single function which
208        is then passed to the decorator. This way we execute each decorator only once.
209        Each capability is first checked whether it's supported/unsupported
210        before executing its `capability_fn` so that each capability is retrieved only once.
211        """
212        supported_conditional_capabilities: set["DecoratedNicCapability"] = set()
213        logger = get_dts_logger(f"{sut_node.name}.{cls.__name__}")
214        if topology.type is topology.type.no_link:
215            logger.debug(
216                "No links available in the current topology, not getting NIC capabilities."
217            )
218            return supported_conditional_capabilities
219        logger.debug(
220            f"Checking which NIC capabilities from {cls.capabilities_to_check} are supported."
221        )
222        if cls.capabilities_to_check:
223            capabilities_to_check_map = cls._get_decorated_capabilities_map()
224            with TestPmdShell(
225                sut_node, privileged=True, disable_device_start=True
226            ) as testpmd_shell:
227                for conditional_capability_fn, capabilities in capabilities_to_check_map.items():
228                    supported_capabilities: set[NicCapability] = set()
229                    unsupported_capabilities: set[NicCapability] = set()
230                    capability_fn = cls._reduce_capabilities(
231                        capabilities, supported_capabilities, unsupported_capabilities
232                    )
233                    if conditional_capability_fn:
234                        capability_fn = conditional_capability_fn(capability_fn)
235                    capability_fn(testpmd_shell)
236                    for capability in capabilities:
237                        if capability.nic_capability in supported_capabilities:
238                            supported_conditional_capabilities.add(capability)
239
240        logger.debug(f"Found supported capabilities {supported_conditional_capabilities}.")
241        return supported_conditional_capabilities
242
243    @classmethod
244    def _get_decorated_capabilities_map(
245        cls,
246    ) -> dict[TestPmdShellDecorator | None, set["DecoratedNicCapability"]]:
247        capabilities_map: dict[TestPmdShellDecorator | None, set["DecoratedNicCapability"]] = {}
248        for capability in cls.capabilities_to_check:
249            if capability.capability_decorator not in capabilities_map:
250                capabilities_map[capability.capability_decorator] = set()
251            capabilities_map[capability.capability_decorator].add(capability)
252
253        return capabilities_map
254
255    @classmethod
256    def _reduce_capabilities(
257        cls,
258        capabilities: set["DecoratedNicCapability"],
259        supported_capabilities: MutableSet,
260        unsupported_capabilities: MutableSet,
261    ) -> TestPmdShellMethod:
262        def reduced_fn(testpmd_shell: TestPmdShell) -> None:
263            for capability in capabilities:
264                if capability not in supported_capabilities | unsupported_capabilities:
265                    capability.capability_fn(
266                        testpmd_shell, supported_capabilities, unsupported_capabilities
267                    )
268
269        return reduced_fn
270
271    def __hash__(self) -> int:
272        """Instances are identified by :attr:`nic_capability` and :attr:`capability_decorator`."""
273        return hash(self.nic_capability)
274
275    def __repr__(self) -> str:
276        """Easy to read string of :attr:`nic_capability` and :attr:`capability_decorator`."""
277        return f"{self.nic_capability}"
278
279
280@dataclass
281class TopologyCapability(Capability):
282    """A wrapper around :class:`~.topology.TopologyType`.
283
284    Each test case must be assigned a topology. It could be done explicitly;
285    the implicit default is :attr:`~.topology.TopologyType.default`, which this class defines
286    as equal to :attr:`~.topology.TopologyType.two_links`.
287
288    Test case topology may be set by setting the topology for the whole suite.
289    The priority in which topology is set is as follows:
290
291        #. The topology set using the :func:`requires` decorator with a test case,
292        #. The topology set using the :func:`requires` decorator with a test suite,
293        #. The default topology if the decorator is not used.
294
295    The default topology of test suite (i.e. when not using the decorator
296    or not setting the topology with the decorator) does not affect the topology of test cases.
297
298    New instances should be created with the :meth:`create_unique` class method to ensure
299    there are no duplicate instances.
300
301    Attributes:
302        topology_type: The topology type that defines each instance.
303    """
304
305    topology_type: TopologyType
306
307    _unique_capabilities: ClassVar[dict[str, Self]] = {}
308
309    def _preprocess_required(self, test_case_or_suite: type["TestProtocol"]) -> None:
310        test_case_or_suite.required_capabilities.discard(test_case_or_suite.topology_type)
311        test_case_or_suite.topology_type = self
312
313    @classmethod
314    def get_unique(cls, topology_type: TopologyType) -> "TopologyCapability":
315        """Get the capability uniquely identified by `topology_type`.
316
317        This is a factory method that implements a quasi-enum pattern.
318        The instances of this class are stored in an internal class variable,
319        `_unique_capabilities`.
320
321        If an instance identified by `topology_type` doesn't exist,
322        it is created and added to `_unique_capabilities`.
323        If it exists, it is returned so that a new identical instance is not created.
324
325        Args:
326            topology_type: The topology type.
327
328        Returns:
329            The capability uniquely identified by `topology_type`.
330        """
331        if topology_type.name not in cls._unique_capabilities:
332            cls._unique_capabilities[topology_type.name] = cls(topology_type)
333        return cls._unique_capabilities[topology_type.name]
334
335    @classmethod
336    def get_supported_capabilities(
337        cls, sut_node: SutNode, topology: "Topology"
338    ) -> set["TopologyCapability"]:
339        """Overrides :meth:`~Capability.get_supported_capabilities`."""
340        supported_capabilities = set()
341        topology_capability = cls.get_unique(topology.type)
342        for topology_type in TopologyType:
343            candidate_topology_type = cls.get_unique(topology_type)
344            if candidate_topology_type <= topology_capability:
345                supported_capabilities.add(candidate_topology_type)
346        return supported_capabilities
347
348    def set_required(self, test_case_or_suite: type["TestProtocol"]) -> None:
349        """The logic for setting the required topology of a test case or suite.
350
351        Decorators are applied on methods of a class first, then on the class.
352        This means we have to modify test case topologies when processing the test suite topologies.
353        At that point, the test case topologies have been set by the :func:`requires` decorator.
354        The test suite topology only affects the test case topologies
355        if not :attr:`~.topology.TopologyType.default`.
356        """
357        if inspect.isclass(test_case_or_suite):
358            if self.topology_type is not TopologyType.default:
359                self.add_to_required(test_case_or_suite)
360                for test_case in test_case_or_suite.get_test_cases():
361                    if test_case.topology_type.topology_type is TopologyType.default:
362                        # test case topology has not been set, use the one set by the test suite
363                        self.add_to_required(test_case)
364                    elif test_case.topology_type > test_case_or_suite.topology_type:
365                        raise ConfigurationError(
366                            "The required topology type of a test case "
367                            f"({test_case.__name__}|{test_case.topology_type}) "
368                            "cannot be more complex than that of a suite "
369                            f"({test_case_or_suite.__name__}|{test_case_or_suite.topology_type})."
370                        )
371        else:
372            self.add_to_required(test_case_or_suite)
373
374    def __eq__(self, other) -> bool:
375        """Compare the :attr:`~TopologyCapability.topology_type`s.
376
377        Args:
378            other: The object to compare with.
379
380        Returns:
381            :data:`True` if the topology types are the same.
382        """
383        return self.topology_type == other.topology_type
384
385    def __lt__(self, other) -> bool:
386        """Compare the :attr:`~TopologyCapability.topology_type`s.
387
388        Args:
389            other: The object to compare with.
390
391        Returns:
392            :data:`True` if the instance's topology type is less complex than the compared object's.
393        """
394        return self.topology_type < other.topology_type
395
396    def __gt__(self, other) -> bool:
397        """Compare the :attr:`~TopologyCapability.topology_type`s.
398
399        Args:
400            other: The object to compare with.
401
402        Returns:
403            :data:`True` if the instance's topology type is more complex than the compared object's.
404        """
405        return other < self
406
407    def __le__(self, other) -> bool:
408        """Compare the :attr:`~TopologyCapability.topology_type`s.
409
410        Args:
411            other: The object to compare with.
412
413        Returns:
414            :data:`True` if the instance's topology type is less complex or equal than
415            the compared object's.
416        """
417        return not self > other
418
419    def __hash__(self):
420        """Each instance is identified by :attr:`topology_type`."""
421        return self.topology_type.__hash__()
422
423    def __str__(self):
424        """Easy to read string of class and name of :attr:`topology_type`.
425
426        Converts :attr:`TopologyType.default` to the actual value.
427        """
428        name = self.topology_type.name
429        if self.topology_type is TopologyType.default:
430            name = TopologyType.get_from_value(self.topology_type.value).name
431        return f"{type(self.topology_type).__name__}.{name}"
432
433    def __repr__(self):
434        """Easy to read string of class and name of :attr:`topology_type`."""
435        return self.__str__()
436
437
438class TestProtocol(Protocol):
439    """Common test suite and test case attributes."""
440
441    #: Whether to skip the test case or suite.
442    skip: ClassVar[bool] = False
443    #: The reason for skipping the test case or suite.
444    skip_reason: ClassVar[str] = ""
445    #: The topology type of the test case or suite.
446    topology_type: ClassVar[TopologyCapability] = TopologyCapability(TopologyType.default)
447    #: The capabilities the test case or suite requires in order to be executed.
448    required_capabilities: ClassVar[set[Capability]] = set()
449
450    @classmethod
451    def get_test_cases(cls) -> list[type["TestCase"]]:
452        """Get test cases. Should be implemented by subclasses containing test cases.
453
454        Raises:
455            NotImplementedError: The subclass does not implement the method.
456        """
457        raise NotImplementedError()
458
459
460def requires(
461    *nic_capabilities: NicCapability,
462    topology_type: TopologyType = TopologyType.default,
463) -> Callable[[type[TestProtocol]], type[TestProtocol]]:
464    """A decorator that adds the required capabilities to a test case or test suite.
465
466    Args:
467        nic_capabilities: The NIC capabilities that are required by the test case or test suite.
468        topology_type: The topology type the test suite or case requires.
469
470    Returns:
471        The decorated test case or test suite.
472    """
473
474    def add_required_capability(test_case_or_suite: type[TestProtocol]) -> type[TestProtocol]:
475        for nic_capability in nic_capabilities:
476            decorated_nic_capability = DecoratedNicCapability.get_unique(nic_capability)
477            decorated_nic_capability.add_to_required(test_case_or_suite)
478
479        topology_capability = TopologyCapability.get_unique(topology_type)
480        topology_capability.set_required(test_case_or_suite)
481
482        return test_case_or_suite
483
484    return add_required_capability
485
486
487def get_supported_capabilities(
488    sut_node: SutNode,
489    topology_config: Topology,
490    capabilities_to_check: set[Capability],
491) -> set[Capability]:
492    """Probe the environment for `capabilities_to_check` and return the supported ones.
493
494    Args:
495        sut_node: The SUT node to check for capabilities.
496        topology_config: The topology config to check for capabilities.
497        capabilities_to_check: The capabilities to check.
498
499    Returns:
500        The capabilities supported by the environment.
501    """
502    callbacks = set()
503    for capability_to_check in capabilities_to_check:
504        callbacks.add(capability_to_check.register_to_check())
505    supported_capabilities = set()
506    for callback in callbacks:
507        supported_capabilities.update(callback(sut_node, topology_config))
508
509    return supported_capabilities
510