Coverage for pyEDAA/Reports/Unittesting/JUnit/GoJUnitReport.py: 89%

145 statements  

« prev     ^ index     » next       coverage.py v7.17.3, created at 2026-11-14 02:26 +0000

1# ==================================================================================================================== # 

2# _____ ____ _ _ ____ _ # 

3# _ __ _ _| ____| _ \ / \ / \ | _ \ ___ _ __ ___ _ __| |_ ___ # 

4# | '_ \| | | | _| | | | |/ _ \ / _ \ | |_) / _ \ '_ \ / _ \| '__| __/ __| # 

5# | |_) | |_| | |___| |_| / ___ \ / ___ \ _| _ < __/ |_) | (_) | | | |_\__ \ # 

6# | .__/ \__, |_____|____/_/ \_\/_/ \_(_)_| \_\___| .__/ \___/|_| \__|___/ # 

7# |_| |___/ |_| # 

8# ==================================================================================================================== # 

9# Authors: # 

10# Patrick Lehmann # 

11# # 

12# License: # 

13# ==================================================================================================================== # 

14# Copyright 2026-2026 Electronic Design Automation Abstraction (EDA²) # 

15# # 

16# Licensed under the Apache License, Version 2.0 (the "License"); # 

17# you may not use this file except in compliance with the License. # 

18# You may obtain a copy of the License at # 

19# # 

20# http://www.apache.org/licenses/LICENSE-2.0 # 

21# # 

22# Unless required by applicable law or agreed to in writing, software # 

23# distributed under the License is distributed on an "AS IS" BASIS, # 

24# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # 

25# See the License for the specific language governing permissions and # 

26# limitations under the License. # 

27# # 

28# SPDX-License-Identifier: Apache-2.0 # 

29# ==================================================================================================================== # 

30# 

31""" 

32Reader and writer for JUnit XML files written by go-junit-report. 

33 

34`go-junit-report <https://github.com/jstemmer/go-junit-report>`__ converts the output of ``go test`` into JUnit XML. A 

35test suite is a Go package named by its import path, a test case is a test, a subtest or an example. 

36""" 

37from __future__ import annotations 

38 

39from datetime import timedelta 

40from pathlib import Path 

41from re import fullmatch 

42from typing import Optional as Nullable, Type, ClassVar 

43 

44from lxml.etree import ElementTree, Element, SubElement, tostring, _Element 

45from pyTooling.Decorators import export, InheritDocString, DocStringMergeStrategy 

46from pyTooling.Stopwatch import Stopwatch 

47 

48from pyEDAA.Reports.Unittesting import UnittestError, TestsuiteKind, TestsuiteSummary as ut_TestsuiteSummary, \ 

49 Testsuite as ut_Testsuite 

50from pyEDAA.Reports.Unittesting.JUnit import Testcase as ju_Testcase, Testclass as ju_Testclass, \ 

51 Testsuite as ju_Testsuite, TestsuiteSummary as ju_TestsuiteSummary, \ 

52 Document as ju_Document 

53 

54 

55@export 

56@InheritDocString(ju_Testcase, DocStringMergeStrategy.BaseLast) 

57class Testcase(ju_Testcase): 

58 """ 

59 This is a derived implementation for the go-junit-report JUnit dialect. 

60 """ 

61 

62 

63@export 

64@InheritDocString(ju_Testclass, DocStringMergeStrategy.BaseLast) 

65class Testclass(ju_Testclass): 

66 """ 

67 This is a derived implementation for the go-junit-report JUnit dialect. 

68 """ 

69 

70 

71@export 

72@InheritDocString(ju_Testsuite, DocStringMergeStrategy.BaseLast) 

73class Testsuite(ju_Testsuite): 

74 """ 

75 This is a derived implementation for the go-junit-report JUnit dialect. 

76 """ 

77 

78 @classmethod 

79 def FromTestsuite(cls, testsuite: ut_Testsuite) -> Testsuite: 

80 """ 

81 Convert a test suite of the unified test entity data model to the JUnit specific data model's test suite object 

82 adhering to the go-junit-report JUnit dialect. 

83 

84 :param testsuite: Test suite from unified data model. 

85 :returns: Test suite from JUnit specific data model (go-junit-report JUnit dialect). 

86 :raises UnittestError: If a test case of the test suite isn't part of a hierarchy. 

87 """ 

88 juTestsuite = cls( 

89 testsuite._name, 

90 hostname=testsuite._hostname, 

91 startTime=testsuite._startTime, 

92 duration=testsuite._totalDuration, 

93 status=testsuite._status, 

94 ) 

95 

96 for tc in testsuite.IterateTestcases(): 

97 ts = tc._parent 

98 if ts is None: 98 ↛ 99line 98 didn't jump to line 99 because the condition on line 98 was never true

99 raise UnittestError(f"Testcase '{tc._name}' is not part of a hierarchy.") 

100 elif ts is testsuite: 

101 juTestsuite.AddTestcase(Testcase.FromTestcase(tc)) 

102 continue 

103 

104 classname = ts._name 

105 ts = ts._parent 

106 while ts is not None and ts._kind > TestsuiteKind.Logical: 

107 classname = f"{ts._name}.{classname}" 

108 ts = ts._parent 

109 

110 if classname in juTestsuite._testclasses: 

111 juClass = juTestsuite._testclasses[classname] 

112 else: 

113 juClass = Testclass(classname, parent=juTestsuite) 

114 

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

116 

117 return juTestsuite 

118 

119 def ToTestsuite(self) -> ut_Testsuite: 

120 """ 

121 Convert this test suite to a test suite of the unified test entity data model. 

122 

123 Each test class becomes a child test suite of kind :attr:`~pyEDAA.Reports.Unittesting.TestsuiteKind.Class`, named 

124 by the whole class name. A class name is a Go import path like ``github.com/user/module/package``, so it isn't 

125 split at ``.`` into packages. 

126 

127 :returns: A test suite of the unified test entity data model. 

128 """ 

129 return ut_Testsuite( 

130 self._name, 

131 kind=TestsuiteKind.Logical, 

132 hostname=self._hostname, 

133 startTime=self._startTime, 

134 totalDuration=self._duration, 

135 status=self._status, 

136 testsuites=(testclass.ToTestsuite() for testclass in self._testclasses.values()), 

137 testcases=(tc.ToTestcase() for tc in self._testcases.values()) 

138 ) 

139 

140 

141@export 

142@InheritDocString(ju_TestsuiteSummary, DocStringMergeStrategy.BaseLast) 

143class TestsuiteSummary(ju_TestsuiteSummary): 

144 """ 

145 This is a derived implementation for the go-junit-report JUnit dialect. 

146 """ 

147 

148 @classmethod 

149 def FromTestsuiteSummary(cls, testsuiteSummary: ut_TestsuiteSummary) -> TestsuiteSummary: 

150 """ 

151 Convert a test suite summary of the unified test entity data model to the JUnit specific data model's test suite 

152 summary object adhering to the go-junit-report JUnit dialect. 

153 

154 :param testsuiteSummary: Test suite summary from unified data model. 

155 :returns: Test suite summary from JUnit specific data model (go-junit-report JUnit dialect). 

156 """ 

157 return cls( 

158 testsuiteSummary._name, 

159 startTime=testsuiteSummary._startTime, 

160 duration=testsuiteSummary._totalDuration, 

161 status=testsuiteSummary._status, 

162 testsuites=(Testsuite.FromTestsuite(testsuite) for testsuite in testsuiteSummary._testsuites.values()) 

163 ) 

164 

165 

166@export 

167class Document(ju_Document): 

168 """ 

169 A document reader and writer for the go-junit-report JUnit XML file format. 

170 

171 This class reads, validates and transforms an XML file in the go-junit-report JUnit format into a JUnit data model. It 

172 can then be converted into a unified test entity data model. 

173 

174 In reverse, a JUnit data model instance with the specific go-junit-report JUnit file format can be created from a 

175 unified test entity data model. This data model can be written as XML into a file. 

176 """ 

177 

178 _DIALECT: ClassVar[str] = "go-junit-report" #: Name of the dialect. 

179 _TESTCASE: ClassVar[Type[Testcase]] = Testcase #: Test case class of the dialect. 

180 _TESTCLASS: ClassVar[Type[Testclass]] = Testclass #: Test class class of the dialect. 

181 _TESTSUITE: ClassVar[Type[Testsuite]] = Testsuite #: Test suite class of the dialect. 

182 

183 def Analyze(self) -> None: 

184 """ 

185 Analyze the XML file, parse the content into an XML data structure and validate the data structure using an XML 

186 schema. 

187 

188 .. hint:: 

189 

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

191 

192 The used XML schema definition is specific to the go-junit-report JUnit dialect. 

193 """ 

194 xmlSchemaFile = "GoJUnitReport-JUnit.xsd" 

195 self._Analyze(xmlSchemaFile) 

196 

197 def Write(self, path: Nullable[Path] = None, overwrite: bool = False, regenerate: bool = False) -> None: 

198 """ 

199 Write the data model as XML into a file adhering to the go-junit-report dialect. 

200 

201 :param path: Optional, path to the XML file, if internal path shouldn't be used. 

202 :param overwrite: Optional, if true, overwrite an existing file. 

203 :param regenerate: Optional, if true, regenerate the XML structure from data model. 

204 :raises UnittestError: If the file cannot be overwritten. 

205 :raises UnittestError: If the internal XML data structure wasn't generated. |br| 

206 Call ``Document.Generate()`` or ``Document.Write(..., regenerate=True)``. 

207 :raises UnittestError: If the file cannot be opened or written. 

208 """ 

209 if path is None: 209 ↛ 212line 209 didn't jump to line 212 because the condition on line 209 was always true

210 path = self._path 

211 

212 if not overwrite and path.exists(): 

213 raise UnittestError(f"JUnit XML file '{path}' can not be overwritten.") \ 

214 from FileExistsError(f"File '{path}' already exists.") 

215 

216 if regenerate: 216 ↛ 219line 216 didn't jump to line 219 because the condition on line 216 was always true

217 self.Generate(overwrite=True) 

218 

219 if self._xmlDocument is None: 219 ↛ 220line 219 didn't jump to line 220 because the condition on line 219 was never true

220 ex = UnittestError("Internal XML document tree is empty and needs to be generated before write is possible.") 

221 ex.add_note("Call 'Document.Generate()' or 'Document.Write(..., regenerate=True)'.") 

222 raise ex 

223 

224 content = tostring(self._xmlDocument, encoding="utf-8", xml_declaration=True, pretty_print=True) 

225 try: 

226 with path.open("wb") as file: 

227 file.write(content) 

228 except OSError as ex: 

229 raise UnittestError(f"JUnit XML file '{path}' can not be written.") from ex 

230 

231 def Convert(self) -> None: 

232 """ 

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

234 

235 This method converts the root element. 

236 

237 .. hint:: 

238 

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

240 

241 :raises UnittestError: If XML was not read and parsed before. |br| 

242 Call ``Document.Analyze()`` or create the document using 

243 ``Document(path, analyzeAndConvert=True)``. 

244 """ 

245 if self._xmlDocument is None: 245 ↛ 246line 245 didn't jump to line 246 because the condition on line 245 was never true

246 ex = UnittestError(f"JUnit XML file '{self._path}' needs to be read and analyzed by an XML parser.") 

247 ex.add_note("Call 'Document.Analyze()' or create the document using 'Document(path, analyzeAndConvert=True)'.") 

248 raise ex 

249 

250 with Stopwatch() as sw: 

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

252 

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

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

255 

256 for rootNode in rootElement.iterchildren(tag="testsuite"): # type: _Element 

257 self._ConvertTestsuite(self, rootNode) 

258 

259 self.Aggregate() 

260 

261 self._modelConversion = sw.Duration 

262 

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

264 """ 

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

266 

267 A ``<testsuite>`` whose ``<system-out>`` is ``go test``'s result line of a package without tests 

268 (``<tab><import path><tab><tab>coverage: ...``) is named by that import path. go-junit-report names such a package 

269 by its option ``-package-name``, which is empty by default. 

270 

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

272 

273 :param parent: The test suite summary as a parent element in the test entity hierarchy. 

274 :param testsuitesNode: The current XML element node representing a test suite. 

275 :raises UnittestError: If the test suite has an empty name and no result line naming the package. 

276 """ 

277 name = self._ConvertName(testsuitesNode, optional=False) 

278 if (systemOutNode := testsuitesNode.find("system-out")) is not None and systemOutNode.text is not None: 

279 if (match := fullmatch(r"\t(?P<importPath>\S+)\t\tcoverage: .+", systemOutNode.text)) is not None: 

280 name = match["importPath"] 

281 

282 if name == "": 282 ↛ 283line 282 didn't jump to line 283 because the condition on line 282 was never true

283 ex = UnittestError(f"Test suite with id '{testsuitesNode.attrib['id']}' has an empty name.") 

284 ex.add_note("go-junit-report names a package by option '-package-name', if it found no result line for it.") 

285 raise ex 

286 

287 newTestsuite = self._TESTSUITE( 

288 name, 

289 self._ConvertHostname(testsuitesNode, default=None, optional=True), 

290 self._ConvertTimestamp(testsuitesNode, optional=True), 

291 self._ConvertTime(testsuitesNode, optional=False), 

292 parent=parent 

293 ) 

294 

295 self._ConvertTestsuiteChildren(testsuitesNode, newTestsuite) 

296 

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

298 """ 

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

300 

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

302 

303 :param overwrite: Optional, overwrite the internal XML data structure. 

304 :raises UnittestError: If overwrite is false and the internal XML data structure is not empty. 

305 """ 

306 if not overwrite and self._xmlDocument is not None: 306 ↛ 307line 306 didn't jump to line 307 because the condition on line 306 was never true

307 raise UnittestError("Internal XML document is populated with data.") 

308 

309 rootElement = Element("testsuites") 

310 rootElement.attrib["name"] = self._name 

311 if self._duration is not None: 311 ↛ 313line 311 didn't jump to line 313 because the condition on line 311 was always true

312 rootElement.attrib["time"] = f"{self._duration.total_seconds():.6f}" 

313 rootElement.attrib["tests"] = str(self._tests) 

314 rootElement.attrib["errors"] = str(self._errored) 

315 rootElement.attrib["failures"] = str(self._failed) 

316 rootElement.attrib["skipped"] = str(self._skipped) 

317 

318 self._xmlDocument = ElementTree(rootElement) 

319 

320 for testsuite in self._testsuites.values(): 

321 self._GenerateTestsuite(testsuite, rootElement) 

322 

323 def _GenerateTestsuite(self, testsuite: Testsuite, parentElement: _Element) -> None: 

324 """ 

325 Generate the internal XML data structure for a test suite. 

326 

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

328 

329 The ``id`` is the test suite's index in the document. Like go-junit-report, a test suite without a duration gets the 

330 sum of its test cases' durations as ``time``. 

331 

332 :param testsuite: The test suite to convert to an XML data structures. 

333 :param parentElement: The parent XML data structure element, this data structure part will be added to. 

334 """ 

335 testsuiteId = len(parentElement) 

336 testsuiteElement = SubElement(parentElement, "testsuite") 

337 testsuiteElement.attrib["name"] = testsuite._name 

338 testsuiteElement.attrib["tests"] = str(testsuite._tests) 

339 testsuiteElement.attrib["failures"] = str(testsuite._failed) 

340 testsuiteElement.attrib["errors"] = str(testsuite._errored) 

341 testsuiteElement.attrib["id"] = str(testsuiteId) 

342 if testsuite._hostname is not None: 

343 testsuiteElement.attrib["hostname"] = testsuite._hostname 

344 testsuiteElement.attrib["skipped"] = str(testsuite._skipped) 

345 if testsuite._duration is not None: 345 ↛ 348line 345 didn't jump to line 348 because the condition on line 345 was always true

346 duration = testsuite._duration 

347 else: 

348 duration = sum( 

349 ( 

350 testcase._duration 

351 for testcases in ( 

352 testsuite._testcases, 

353 *(testclass._testcases for testclass in testsuite._testclasses.values()) 

354 ) 

355 for testcase in testcases.values() 

356 if testcase._duration is not None 

357 ), 

358 timedelta() 

359 ) 

360 testsuiteElement.attrib["time"] = f"{duration.total_seconds():.6f}" 

361 if testsuite._startTime is not None: 

362 testsuiteElement.attrib["timestamp"] = f"{testsuite._startTime.isoformat()}" 

363 

364 for tc in testsuite._testcases.values(): 

365 self._GenerateTestcase(tc, testsuiteElement) 

366 

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

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

369 self._GenerateTestcase(tc, testsuiteElement) 

370 

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

372 """ 

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

374 

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

376 

377 :param testcase: The test case to convert to an XML data structures. 

378 :param parentElement: The parent XML data structure element, this data structure part will be added to. 

379 """ 

380 testcaseElement = SubElement(parentElement, "testcase") 

381 testcaseElement.attrib["name"] = testcase._name 

382 testcaseElement.attrib["classname"] = testcase.Classname 

383 if testcase._duration is not None: 383 ↛ 386line 383 didn't jump to line 386 because the condition on line 383 was always true

384 testcaseElement.attrib["time"] = f"{testcase._duration.total_seconds():.6f}" 

385 

386 self._GenerateTestcaseChildren(testcase, testcaseElement) 

387 

388 def _GenerateTestcaseChildren(self, testcase: Testcase, testcaseElement: _Element) -> None: 

389 """ 

390 Generate the child elements of a ``<testcase>`` from the test case's status, message, details and captured output. 

391 

392 Like go-junit-report, every ``<skipped>``, ``<error>`` and ``<failure>`` element has a ``message`` attribute; it's 

393 empty for a test case without a message. 

394 

395 :param testcase: The test case to convert to XML child elements. 

396 :param testcaseElement: The ``<testcase>`` element, the child elements will be added to. 

397 """ 

398 super()._GenerateTestcaseChildren(testcase, testcaseElement) 

399 

400 for statusElement in testcaseElement.iterchildren("skipped", "error", "failure"): # type: _Element 

401 if "message" not in statusElement.attrib: 

402 statusElement.attrib["message"] = ""