File: test_result.py

package info (click to toggle)
dpdk 25.11-2
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 127,892 kB
  • sloc: ansic: 2,358,479; python: 16,426; sh: 4,474; makefile: 1,713; awk: 70
file content (329 lines) | stat: -rw-r--r-- 11,701 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
# SPDX-License-Identifier: BSD-3-Clause
# Copyright(c) 2023 PANTHEON.tech s.r.o.
# Copyright(c) 2023 University of New Hampshire
# Copyright(c) 2024 Arm Limited

r"""Record and process DTS results.

The results are recorded in a hierarchical manner:

    * :class:`TestRunResult` contains
    * :class:`ResultNode` may contain itself or
    * :class:`ResultLeaf`

Each result may contain many intermediate steps, e.g. there are multiple
:class:`ResultNode`\s in a :class:`ResultNode`.

The :option:`--output` command line argument and the :envvar:`DTS_OUTPUT_DIR` environment
variable modify the directory where the files with results will be stored.
"""

import sys
from collections import Counter
from enum import IntEnum, auto
from io import StringIO
from pathlib import Path
from typing import Any, ClassVar, Literal, TextIO, Union

from pydantic import (
    BaseModel,
    ConfigDict,
    Field,
    computed_field,
    field_serializer,
    model_serializer,
)
from typing_extensions import OrderedDict

from framework.remote_session.dpdk import DPDKBuildInfo
from framework.settings import SETTINGS
from framework.testbed_model.os_session import OSSessionInfo

from .exception import DTSError, ErrorSeverity, InternalError


class Result(IntEnum):
    """The possible states that a setup, a teardown or a test case may end up in."""

    #:
    PASS = auto()
    #:
    SKIP = auto()
    #:
    BLOCK = auto()
    #:
    FAIL = auto()
    #:
    ERROR = auto()

    def __bool__(self) -> bool:
        """Only :attr:`PASS` is True."""
        return self is self.PASS


class ResultLeaf(BaseModel):
    """Class representing a result in the results tree.

    A leaf node that can contain the results for a :class:`~.test_suite.TestSuite`,
    :class:`.test_suite.TestCase` or a DTS execution step.

    Attributes:
        result: The actual result.
        reason: The reason of the result.
    """

    model_config = ConfigDict(arbitrary_types_allowed=True)

    result: Result
    reason: DTSError | None = None

    def __lt__(self, other: object) -> bool:
        """Compare another instance of the same class by :attr:`~ResultLeaf.result`."""
        if isinstance(other, ResultLeaf):
            return self.result < other.result
        return True

    def __eq__(self, other: object) -> bool:
        """Compare equality with compatible classes by :attr:`~ResultLeaf.result`."""
        match other:
            case ResultLeaf(result=result):
                return self.result == result
            case Result():
                return self.result == other
            case _:
                return False


ExecutionStep = Literal["setup", "teardown"]
"""Predefined execution steps."""


class ResultNode(BaseModel):
    """Class representing a node in the tree of results.

    Each node contains a label and a list of children, which can be either :class:`~.ResultNode`, or
    :class:`~.ResultLeaf`. This node is serialized as a dictionary of the children. The key of each
    child is either ``result`` in the case of a :class:`~.ResultLeaf`, or it is the value of
    :attr:`~.ResultNode.label`.

    Attributes:
        label: The name of the node.
        children: A list of either :class:`~.ResultNode` or :class:`~.ResultLeaf`.
        parent: The parent node, if any.
    """

    __ignore_steps: ClassVar[list[ExecutionStep]] = ["setup", "teardown"]

    label: str
    children: list[Union["ResultNode", ResultLeaf]] = Field(default_factory=list)
    parent: Union["ResultNode", None] = None

    def add_child(self, label: str) -> "ResultNode":
        """Creates and append a child node to the model.

        Args:
            label: The name of the node.
        """
        child = ResultNode(label=label, parent=self)
        self.children.append(child)
        return child

    def mark_result_as(self, result: Result, ex: BaseException | None = None) -> None:
        """Mark result for the current step.

        Args:
            result: The result of the current step.
            ex: The exception if any occurred. If this is not an instance of DTSError, it is wrapped
                with an InternalError.
        """
        if ex is None or isinstance(ex, DTSError):
            reason = ex
        else:
            reason = InternalError(f"Unhandled exception raised: {ex}")

        result_leaf = next((child for child in self.children if type(child) is ResultLeaf), None)
        if result_leaf:
            result_leaf.result = result
            result_leaf.reason = reason
        else:
            self.children.append(ResultLeaf(result=result, reason=reason))

    def mark_step_as(
        self, step: ExecutionStep, result: Result, ex: BaseException | None = None
    ) -> None:
        """Mark an execution step with the given result.

        Args:
            step: Step to mark, e.g.: setup, teardown.
            result: The result of the execution step.
            ex: The exception if any occurred. If this is not an instance of DTSError, it is wrapped
                with an InternalError.
        """
        try:
            step_node = next(
                child
                for child in self.children
                if type(child) is ResultNode and child.label == step
            )
        except StopIteration:
            step_node = self.add_child(step)
        step_node.mark_result_as(result, ex)

    @model_serializer
    def serialize_model(self) -> dict[str, Any]:
        """Serializes model output."""
        obj: dict[str, Any] = OrderedDict()

        for child in self.children:
            match child:
                case ResultNode(label=label):
                    obj[label] = child
                case ResultLeaf(result=result, reason=reason):
                    obj["result"] = result.name
                    if reason is not None:
                        obj["reason"] = str(reason)

        return obj

    def get_overall_result(self) -> ResultLeaf:
        """The overall result of the underlying results."""

        def extract_result(value: ResultNode | ResultLeaf) -> ResultLeaf:
            match value:
                case ResultNode():
                    return value.get_overall_result()
                case ResultLeaf():
                    return value

        return max(
            (extract_result(child) for child in self.children),
            default=ResultLeaf(result=Result.PASS),
        )

    def make_summary(self) -> Counter[Result]:
        """Make the summary of the underlying results while ignoring special nodes."""
        counter: Counter[Result] = Counter()
        for child in self.children:
            match child:
                case ResultNode(label=label) if label not in self.__ignore_steps:
                    counter += child.make_summary()
                case ResultLeaf(result=result):
                    counter[result] += 1
        return counter

    def print_results(
        self, file: TextIO = sys.stdout, indent_level: int = 0, indent_width: int = 2
    ) -> None:
        """Print the results in a textual tree format."""

        def indent(extra_level: int = 0) -> str:
            return (indent_level + extra_level) * indent_width * " "

        overall_result = self.get_overall_result()
        if self.label in self.__ignore_steps and overall_result == Result.PASS:
            return

        print(f"{indent()}{self.label}: {overall_result.result.name}", file=file)

        for child in self.children:
            match child:
                case ResultNode():
                    child.print_results(file, indent_level + 1, indent_width)
                case ResultLeaf(reason=reason) if reason is not None:
                    # The result is already printed as part of `overall_result` above.
                    print(f"{indent(1)}reason: {reason}", file=file)


class TestRunResult(BaseModel):
    """Class representing the root node of the results tree.

    Root node of the model containing metadata about the DPDK version, ports, compiler and
    DTS execution results.

    Attributes:
        sut_session_info: The SUT node OS session information.
        dpdk_build_info: The DPDK build information.
        ports: The ports that were used in the test run.
        test_suites: The test suites containing the results of DTS execution.
        execution_errors: A list of errors that occur during DTS execution.
    """

    model_config = ConfigDict(arbitrary_types_allowed=True)

    json_filepath: ClassVar[Path] = Path(SETTINGS.output_dir, "results.json")
    summary_filepath: ClassVar[Path] = Path(SETTINGS.output_dir, "results_summary.txt")

    sut_session_info: OSSessionInfo | None = None
    dpdk_build_info: DPDKBuildInfo | None = None
    ports: list[dict[str, str]] | None = None
    test_suites: ResultNode
    execution_errors: list[DTSError] = Field(default_factory=list)

    @field_serializer("execution_errors", when_used="json")
    def serialize_errors(self, execution_errors: list[DTSError]) -> list[str]:
        """Serialize errors as plain text."""
        return [str(err) for err in execution_errors]

    def add_error(self, ex: BaseException) -> None:
        """Add an execution error to the test run result."""
        if isinstance(ex, DTSError):
            self.execution_errors.append(ex)
        else:
            self.execution_errors.append(InternalError(f"Unhandled exception raised: {ex}"))

    @computed_field  # type: ignore[prop-decorator]
    @property
    def summary(self) -> dict[str, int]:
        """The test cases result summary."""
        summary = self.test_suites.make_summary()
        total_without_skip = (
            sum(total for result, total in summary.items() if result != Result.SKIP) or 1
        )

        final_summary = OrderedDict((result.name, summary[result]) for result in Result)
        final_summary["PASS_RATE"] = int(final_summary["PASS"] / total_without_skip * 100)
        return final_summary

    @property
    def return_code(self) -> int:
        """Gather all the errors and return a code by highest severity."""
        codes = [err.severity for err in self.execution_errors]
        if err := self.test_suites.get_overall_result().reason:
            codes.append(err.severity)
        return max(codes, default=ErrorSeverity.NO_ERR).value

    def print_summary(self, file: TextIO = sys.stdout) -> None:
        """Print out the textual summary."""
        print("Results", file=file)
        print("=======", file=file)
        self.test_suites.print_results(file)
        print(file=file)

        print("Test Cases Summary", file=file)
        print("==================", file=file)
        summary = self.summary
        padding = max(len(result_label) for result_label in self.summary.keys())
        for result_label, total in summary.items():
            if result_label == "PASS_RATE":
                print(f"{'PASS RATE': <{padding}} = {total}%", file=file)
            else:
                print(f"{result_label: <{padding}} = {total}", file=file)

    def dump_json(self, file: TextIO = sys.stdout, /, indent: int = 4) -> None:
        """Dump the results as JSON."""
        file.write(self.model_dump_json(indent=indent))

    def process(self) -> int:
        """Process and store all the results, and return the resulting exit code."""
        with open(self.json_filepath, "w") as json_file:
            self.dump_json(json_file)

        summary = StringIO()
        self.print_summary(summary)
        with open(self.summary_filepath, "w") as summary_file:
            summary_file.write(summary.getvalue())

        print()
        print(summary.getvalue())

        return self.return_code