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

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 

36 

37from pathlib import Path 

38from typing import Optional as Nullable, Generator, Tuple, Union, TypeVar, Type, ClassVar 

39 

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 

44 

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 

50 

51 

52TestsuiteType = TypeVar("TestsuiteType", bound="Testsuite") 

53TestcaseAggregateReturnType = Tuple[int, int, int] 

54TestsuiteAggregateReturnType = Tuple[int, int, int, int, int] 

55 

56 

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 """ 

63 

64 

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 """ 

71 

72 

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 """ 

79 

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. 

85 

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 ) 

96 

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 

102 

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.") 

107 

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 

113 

114 if classname in juTestsuite._testclasses: 

115 juClass = juTestsuite._testclasses[classname] 

116 else: 

117 juClass = Testclass(classname, parent=juTestsuite) 

118 

119 juClass.AddTestcase(Testcase.FromTestcase(tc)) 

120 

121 return juTestsuite 

122 

123 

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 """ 

130 

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. 

136 

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 ) 

147 

148 

149@export 

150class Document(ju_Document): 

151 """ 

152 A document reader and writer for the CTest JUnit XML file format. 

153 

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. 

156 

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 """ 

160 

161 _DIALECT: ClassVar[str] = "CTest + JUnit" 

162 _TESTCASE: ClassVar[Type[Testcase]] = Testcase 

163 _TESTCLASS: ClassVar[Type[Testclass]] = Testclass 

164 _TESTSUITE: ClassVar[Type[Testsuite]] = Testsuite 

165 

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. 

170 

171 .. hint:: 

172 

173 The time spend for analysis will be made available via property :data:`AnalysisDuration`. 

174 

175 The used XML schema definition is specific to the CTest JUnit dialect. 

176 """ 

177 xmlSchemaFile = "CTest-JUnit.xsd" 

178 self._Analyze(xmlSchemaFile) 

179 

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. 

183 

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 

193 

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.") 

197 

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) 

200 

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 

205 

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 

212 

213 def Convert(self) -> None: 

214 """ 

215 Convert the parsed and validated XML data structure into a JUnit test entity hierarchy. 

216 

217 This method converts the root element. 

218 

219 .. hint:: 

220 

221 The time spend for model conversion will be made available via property :data:`ModelConversionDuration`. 

222 

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 

229 

230 with Stopwatch() as sw: 

231 rootElement: _Element = self._xmlDocument.getroot() 

232 

233 self._name = self._ConvertName(rootElement, optional=True) 

234 self._startTime =self._ConvertTimestamp(rootElement, optional=True) 

235 self._duration = self._ConvertTime(rootElement, optional=True) 

236 

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") 

242 

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) 

246 

247 self.Aggregate() 

248 

249 self._modelConversion = sw.Duration 

250 

251 def _ConvertTestsuite(self, parent: TestsuiteSummary, testsuitesNode: _Element) -> None: 

252 """ 

253 Convert the XML data structure of a ``<testsuite>`` to a test suite. 

254 

255 This method uses private helper methods provided by the base-class. 

256 

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 ) 

267 

268 self._ConvertTestsuiteChildren(testsuitesNode, newTestsuite) 

269 

270 def Generate(self, overwrite: bool = False) -> None: 

271 """ 

272 Generate the internal XML data structure from test suites and test cases. 

273 

274 This method generates the XML root element (``<testsuite>``) and recursively calls other generated methods. 

275 

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.") 

281 

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 

286 

287 testsuite = firstValue(self._testsuites) 

288 

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" 

307 

308 self._xmlDocument = ElementTree(rootElement) 

309 

310 for testclass in testsuite._testclasses.values(): 

311 for tc in testclass._testcases.values(): 

312 self._GenerateTestcase(tc, rootElement) 

313 

314 def _GenerateTestcase(self, testcase: Testcase, parentElement: _Element) -> None: 

315 """ 

316 Generate the internal XML data structure for a test case. 

317 

318 This method generates the XML element (``<testcase>``) and recursively calls other generated methods. 

319 

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}" 

331 

332 testcaseElement.attrib["status"] = "run" # TODO: find a value 

333 

334 self._GenerateTestcaseChildren(testcase, testcaseElement)