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