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
|