Coverage for pyEDAA/Reports/Unittesting/JUnit/CTestJUnit.py: 89%
131 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-21 23:49 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-21 23:49 +0000
1# ==================================================================================================================== #
2# _____ ____ _ _ ____ _ #
3# _ __ _ _| ____| _ \ / \ / \ | _ \ ___ _ __ ___ _ __| |_ ___ #
4# | '_ \| | | | _| | | | |/ _ \ / _ \ | |_) / _ \ '_ \ / _ \| '__| __/ __| #
5# | |_) | |_| | |___| |_| / ___ \ / ___ \ _| _ < __/ |_) | (_) | | | |_\__ \ #
6# | .__/ \__, |_____|____/_/ \_\/_/ \_(_)_| \_\___| .__/ \___/|_| \__|___/ #
7# |_| |___/ |_| #
8# ==================================================================================================================== #
9# Authors: #
10# Patrick Lehmann #
11# #
12# License: #
13# ==================================================================================================================== #
14# Copyright 2024-2026 Electronic Design Automation Abstraction (EDA²) #
15# Copyright 2023-2023 Patrick Lehmann - Bötzingen, Germany #
16# #
17# Licensed under the Apache License, Version 2.0 (the "License"); #
18# you may not use this file except in compliance with the License. #
19# You may obtain a copy of the License at #
20# #
21# http://www.apache.org/licenses/LICENSE-2.0 #
22# #
23# Unless required by applicable law or agreed to in writing, software #
24# distributed under the License is distributed on an "AS IS" BASIS, #
25# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. #
26# See the License for the specific language governing permissions and #
27# limitations under the License. #
28# #
29# SPDX-License-Identifier: Apache-2.0 #
30# ==================================================================================================================== #
31#
32"""
33Reader for JUnit unit testing summary files in XML format.
34"""
35from __future__ import annotations
37from pathlib import Path
38from typing import Optional as Nullable, Generator, Tuple, Union, TypeVar, Type, ClassVar
40from lxml.etree import ElementTree, Element, SubElement, tostring, _Element
41from pyTooling.Common import firstValue
42from pyTooling.Decorators import export, InheritDocString, DocStringMergeStrategy
43from pyTooling.Stopwatch import Stopwatch
45from pyEDAA.Reports.Unittesting import UnittestError, TestsuiteKind
46from pyEDAA.Reports.Unittesting import TestcaseStatus, TestsuiteStatus, IterationScheme
47from pyEDAA.Reports.Unittesting import TestsuiteSummary as ut_TestsuiteSummary, Testsuite as ut_Testsuite
48from pyEDAA.Reports.Unittesting.JUnit import Testcase as ju_Testcase, Testclass as ju_Testclass, Testsuite as ju_Testsuite
49from pyEDAA.Reports.Unittesting.JUnit import TestsuiteSummary as ju_TestsuiteSummary, Document as ju_Document
52TestsuiteType = TypeVar("TestsuiteType", bound="Testsuite")
53TestcaseAggregateReturnType = Tuple[int, int, int]
54TestsuiteAggregateReturnType = Tuple[int, int, int, int, int]
57@export
58@InheritDocString(ju_Testcase, DocStringMergeStrategy.BaseLast)
59class Testcase(ju_Testcase):
60 """
61 This is a derived implementation for the CTest JUnit dialect.
62 """
65@export
66@InheritDocString(ju_Testclass, DocStringMergeStrategy.BaseLast)
67class Testclass(ju_Testclass):
68 """
69 This is a derived implementation for the CTest JUnit dialect.
70 """
73@export
74@InheritDocString(ju_Testsuite, DocStringMergeStrategy.BaseLast)
75class Testsuite(ju_Testsuite):
76 """
77 This is a derived implementation for the CTest JUnit dialect.
78 """
80 @classmethod
81 def FromTestsuite(cls, testsuite: ut_Testsuite) -> Testsuite:
82 """
83 Convert a test suite of the unified test entity data model to the JUnit specific data model's test suite object
84 adhering to the CTest JUnit dialect.
86 :param testsuite: Test suite from unified data model.
87 :returns: Test suite from JUnit specific data model (CTest JUnit dialect).
88 """
89 juTestsuite = cls(
90 testsuite._name,
91 hostname=testsuite._hostname,
92 startTime=testsuite._startTime,
93 duration=testsuite._totalDuration,
94 status= testsuite._status,
95 )
97 juTestsuite._tests = testsuite._tests
98 juTestsuite._skipped = testsuite._skipped
99 juTestsuite._errored = testsuite._errored
100 juTestsuite._failed = testsuite._failed
101 juTestsuite._passed = testsuite._passed
103 for tc in testsuite.IterateTestcases():
104 ts = tc._parent
105 if ts is None: 105 ↛ 106line 105 didn't jump to line 106 because the condition on line 105 was never true
106 raise UnittestError(f"Testcase '{tc._name}' is not part of a hierarchy.")
108 classname = ts._name
109 ts = ts._parent
110 while ts is not None and ts._kind > TestsuiteKind.Logical:
111 classname = f"{ts._name}.{classname}"
112 ts = ts._parent
114 if classname in juTestsuite._testclasses:
115 juClass = juTestsuite._testclasses[classname]
116 else:
117 juClass = Testclass(classname, parent=juTestsuite)
119 juClass.AddTestcase(Testcase.FromTestcase(tc))
121 return juTestsuite
124@export
125@InheritDocString(ju_TestsuiteSummary, DocStringMergeStrategy.BaseLast)
126class TestsuiteSummary(ju_TestsuiteSummary):
127 """
128 This is a derived implementation for the CTest JUnit dialect.
129 """
131 @classmethod
132 def FromTestsuiteSummary(cls, testsuiteSummary: ut_TestsuiteSummary) -> TestsuiteSummary:
133 """
134 Convert a test suite summary of the unified test entity data model to the JUnit specific data model's test suite
135 summary object adhering to the CTest JUnit dialect.
137 :param testsuiteSummary: Test suite summary from unified data model.
138 :returns: Test suite summary from JUnit specific data model (CTest JUnit dialect).
139 """
140 return cls(
141 testsuiteSummary._name,
142 startTime=testsuiteSummary._startTime,
143 duration=testsuiteSummary._totalDuration,
144 status=testsuiteSummary._status,
145 testsuites=(Testsuite.FromTestsuite(testsuite) for testsuite in testsuiteSummary._testsuites.values())
146 )
149@export
150class Document(ju_Document):
151 """
152 A document reader and writer for the CTest JUnit XML file format.
154 This class reads, validates and transforms an XML file in the CTest JUnit format into a JUnit data model. It can then
155 be converted into a unified test entity data model.
157 In reverse, a JUnit data model instance with the specific CTest JUnit file format can be created from a unified test
158 entity data model. This data model can be written as XML into a file.
159 """
161 _DIALECT: ClassVar[str] = "CTest + JUnit"
162 _TESTCASE: ClassVar[Type[Testcase]] = Testcase
163 _TESTCLASS: ClassVar[Type[Testclass]] = Testclass
164 _TESTSUITE: ClassVar[Type[Testsuite]] = Testsuite
166 def Analyze(self) -> None:
167 """
168 Analyze the XML file, parse the content into an XML data structure and validate the data structure using an XML
169 schema.
171 .. hint::
173 The time spend for analysis will be made available via property :data:`AnalysisDuration`.
175 The used XML schema definition is specific to the CTest JUnit dialect.
176 """
177 xmlSchemaFile = "CTest-JUnit.xsd"
178 self._Analyze(xmlSchemaFile)
180 def Write(self, path: Nullable[Path] = None, overwrite: bool = False, regenerate: bool = False) -> None:
181 """
182 Write the data model as XML into a file adhering to the CTest dialect.
184 :param path: Optional, path to the XML file, if internal path shouldn't be used.
185 :param overwrite: Optional, if true, overwrite an existing file.
186 :param regenerate: Optional, if true, regenerate the XML structure from data model.
187 :raises UnittestError: If the file cannot be overwritten.
188 :raises UnittestError: If the internal XML data structure wasn't generated.
189 :raises UnittestError: If the file cannot be opened or written.
190 """
191 if path is None:
192 path = self._path
194 if not overwrite and path.exists():
195 raise UnittestError(f"JUnit XML file '{path}' can not be overwritten.") \
196 from FileExistsError(f"File '{path}' already exists.")
198 if regenerate: 198 ↛ 201line 198 didn't jump to line 201 because the condition on line 198 was always true
199 self.Generate(overwrite=True)
201 if self._xmlDocument is None: 201 ↛ 202line 201 didn't jump to line 202 because the condition on line 201 was never true
202 ex = UnittestError("Internal XML document tree is empty and needs to be generated before write is possible.")
203 ex.add_note("Call 'JUnitDocument.Generate()' or 'JUnitDocument.Write(..., regenerate=True)'.")
204 raise ex
206 content = tostring(self._xmlDocument, encoding="utf-8", xml_declaration=True, pretty_print=True)
207 try:
208 with path.open("wb") as file:
209 file.write(content)
210 except OSError as ex:
211 raise UnittestError(f"JUnit XML file '{path}' can not be written.") from ex
213 def Convert(self) -> None:
214 """
215 Convert the parsed and validated XML data structure into a JUnit test entity hierarchy.
217 This method converts the root element.
219 .. hint::
221 The time spend for model conversion will be made available via property :data:`ModelConversionDuration`.
223 :raises UnittestError: If XML was not read and parsed before.
224 """
225 if self._xmlDocument is None: 225 ↛ 226line 225 didn't jump to line 226 because the condition on line 225 was never true
226 ex = UnittestError(f"JUnit XML file '{self._path}' needs to be read and analyzed by an XML parser.")
227 ex.add_note("Call 'JUnitDocument.Analyze()' or create the document using 'JUnitDocument(path, parse=True)'.")
228 raise ex
230 with Stopwatch() as sw:
231 rootElement: _Element = self._xmlDocument.getroot()
233 self._name = self._ConvertName(rootElement, optional=True)
234 self._startTime =self._ConvertTimestamp(rootElement, optional=True)
235 self._duration = self._ConvertTime(rootElement, optional=True)
237 # tests = rootElement.getAttribute("tests")
238 # skipped = rootElement.getAttribute("skipped")
239 # errors = rootElement.getAttribute("errors")
240 # failures = rootElement.getAttribute("failures")
241 # assertions = rootElement.getAttribute("assertions")
243 hostname = self._ConvertHostname(rootElement, optional=True, default=None)
244 ts = Testsuite(self._name, hostname, startTime=self._startTime, duration=self._duration, parent=self)
245 self._ConvertTestsuiteChildren(rootElement, ts)
247 self.Aggregate()
249 self._modelConversion = sw.Duration
251 def _ConvertTestsuite(self, parent: TestsuiteSummary, testsuitesNode: _Element) -> None:
252 """
253 Convert the XML data structure of a ``<testsuite>`` to a test suite.
255 This method uses private helper methods provided by the base-class.
257 :param parent: The test suite summary as a parent element in the test entity hierarchy.
258 :param testsuitesNode: The current XML element node representing a test suite.
259 """
260 newTestsuite = Testsuite(
261 self._ConvertName(testsuitesNode, optional=False),
262 self._ConvertHostname(testsuitesNode, optional=False),
263 self._ConvertTimestamp(testsuitesNode, optional=False),
264 self._ConvertTime(testsuitesNode, optional=False),
265 parent=parent
266 )
268 self._ConvertTestsuiteChildren(testsuitesNode, newTestsuite)
270 def Generate(self, overwrite: bool = False) -> None:
271 """
272 Generate the internal XML data structure from test suites and test cases.
274 This method generates the XML root element (``<testsuite>``) and recursively calls other generated methods.
276 :param overwrite: Optional, overwrite the internal XML data structure.
277 :raises UnittestError: If overwrite is false and the internal XML data structure is not empty.
278 """
279 if not overwrite and self._xmlDocument is not None: 279 ↛ 280line 279 didn't jump to line 280 because the condition on line 279 was never true
280 raise UnittestError("Internal XML document is populated with data.")
282 if self.TestsuiteCount != 1:
283 ex = UnittestError("The CTest JUnit format requires exactly one test suite.")
284 ex.add_note(f"Found {self.TestsuiteCount} test suites.")
285 raise ex
287 testsuite = firstValue(self._testsuites)
289 rootElement = Element("testsuite")
290 rootElement.attrib["name"] = self._name
291 if self._startTime is None:
292 raise UnittestError(
293 f"The {self._DIALECT} format requires a timestamp on <testsuite>, but the report has none."
294 )
295 rootElement.attrib["timestamp"] = f"{self._startTime.isoformat()}"
296 if self._duration is not None:
297 rootElement.attrib["time"] = f"{self._duration.total_seconds():.6f}"
298 rootElement.attrib["tests"] = str(self._tests)
299 rootElement.attrib["failures"] = str(self._failed)
300 # rootElement.attrib["errors"] = str(self._errored)
301 rootElement.attrib["skipped"] = str(self._skipped)
302 rootElement.attrib["disabled"] = "0" # TODO: find a value
303 # if self._assertionCount is not None:
304 # rootElement.attrib["assertions"] = f"{self._assertionCount}"
305 # CTest-JUnit.xsd requires 'hostname', so an unrecorded host is named as unknown.
306 rootElement.attrib["hostname"] = testsuite._hostname if testsuite._hostname is not None else "unknownhost"
308 self._xmlDocument = ElementTree(rootElement)
310 for testclass in testsuite._testclasses.values():
311 for tc in testclass._testcases.values():
312 self._GenerateTestcase(tc, rootElement)
314 def _GenerateTestcase(self, testcase: Testcase, parentElement: _Element) -> None:
315 """
316 Generate the internal XML data structure for a test case.
318 This method generates the XML element (``<testcase>``) and recursively calls other generated methods.
320 :param testcase: The test case to convert to an XML data structures.
321 :param parentElement: The parent XML data structure element, this data structure part will be added to.
322 """
323 testcaseElement = SubElement(parentElement, "testcase")
324 if testcase.Classname is not None: 324 ↛ 326line 324 didn't jump to line 326 because the condition on line 324 was always true
325 testcaseElement.attrib["classname"] = testcase.Classname
326 testcaseElement.attrib["name"] = testcase._name
327 if testcase._duration is not None: 327 ↛ 329line 327 didn't jump to line 329 because the condition on line 327 was always true
328 testcaseElement.attrib["time"] = f"{testcase._duration.total_seconds():.6f}"
329 if testcase._assertionCount is not None: 329 ↛ 330line 329 didn't jump to line 330 because the condition on line 329 was never true
330 testcaseElement.attrib["assertions"] = f"{testcase._assertionCount}"
332 testcaseElement.attrib["status"] = "run" # TODO: find a value
334 self._GenerateTestcaseChildren(testcase, testcaseElement)