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

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

49from pyEDAA.Reports.Unittesting import TestsuiteSummary as ut_TestsuiteSummary, Testsuite as ut_Testsuite 

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

51from pyEDAA.Reports.Unittesting.JUnit import Testsuite as ju_Testsuite, TestsuiteSummary as ju_TestsuiteSummary 

52from pyEDAA.Reports.Unittesting.JUnit import 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 

101 classname = ts._name 

102 ts = ts._parent 

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

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

105 ts = ts._parent 

106 

107 if classname in juTestsuite._testclasses: 

108 juClass = juTestsuite._testclasses[classname] 

109 else: 

110 juClass = Testclass(classname, parent=juTestsuite) 

111 

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

113 

114 return juTestsuite 

115 

116 def ToTestsuite(self) -> ut_Testsuite: 

117 """ 

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

119 

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

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

122 split at ``.`` into packages. 

123 

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

125 """ 

126 return ut_Testsuite( 

127 self._name, 

128 kind=TestsuiteKind.Logical, 

129 hostname=self._hostname, 

130 startTime=self._startTime, 

131 totalDuration=self._duration, 

132 status=self._status, 

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

134 ) 

135 

136 

137@export 

138@InheritDocString(ju_TestsuiteSummary, DocStringMergeStrategy.BaseLast) 

139class TestsuiteSummary(ju_TestsuiteSummary): 

140 """ 

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

142 """ 

143 

144 @classmethod 

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

146 """ 

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

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

149 

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

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

152 """ 

153 return cls( 

154 testsuiteSummary._name, 

155 startTime=testsuiteSummary._startTime, 

156 duration=testsuiteSummary._totalDuration, 

157 status=testsuiteSummary._status, 

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

159 ) 

160 

161 

162@export 

163class Document(ju_Document): 

164 """ 

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

166 

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

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

169 

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

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

172 """ 

173 

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

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

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

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

178 

179 def Analyze(self) -> None: 

180 """ 

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

182 schema. 

183 

184 .. hint:: 

185 

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

187 

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

189 """ 

190 xmlSchemaFile = "GoJUnitReport-JUnit.xsd" 

191 self._Analyze(xmlSchemaFile) 

192 

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

194 """ 

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

196 

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

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

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

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

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

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

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

204 """ 

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

206 path = self._path 

207 

208 if not overwrite and path.exists(): 

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

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

211 

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

213 self.Generate(overwrite=True) 

214 

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

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

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

218 raise ex 

219 

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

221 try: 

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

223 file.write(content) 

224 except OSError as ex: 

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

226 

227 def Convert(self) -> None: 

228 """ 

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

230 

231 This method converts the root element. 

232 

233 .. hint:: 

234 

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

236 

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

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

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

240 """ 

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

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

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

244 raise ex 

245 

246 with Stopwatch() as sw: 

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

248 

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

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

251 

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

253 self._ConvertTestsuite(self, rootNode) 

254 

255 self.Aggregate() 

256 

257 self._modelConversion = sw.Duration 

258 

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

260 """ 

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

262 

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

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

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

266 

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

268 

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

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

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

272 """ 

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

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

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

276 name = match["importPath"] 

277 

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

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

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

281 raise ex 

282 

283 newTestsuite = self._TESTSUITE( 

284 name, 

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

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

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

288 parent=parent 

289 ) 

290 

291 self._ConvertTestsuiteChildren(testsuitesNode, newTestsuite) 

292 

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

294 """ 

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

296 

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

298 

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

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

301 """ 

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

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

304 

305 rootElement = Element("testsuites") 

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

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

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

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

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

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

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

313 

314 self._xmlDocument = ElementTree(rootElement) 

315 

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

317 self._GenerateTestsuite(testsuite, rootElement) 

318 

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

320 """ 

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

322 

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

324 

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

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

327 

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

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

330 """ 

331 testsuiteId = len(parentElement) 

332 testsuiteElement = SubElement(parentElement, "testsuite") 

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

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

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

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

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

338 if testsuite._hostname is not None: 

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

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

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

342 duration = testsuite._duration 

343 else: 

344 duration = sum( 

345 ( 

346 testcase._duration 

347 for testclass in testsuite._testclasses.values() 

348 for testcase in testclass._testcases.values() 

349 if testcase._duration is not None 

350 ), 

351 timedelta() 

352 ) 

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

354 if testsuite._startTime is not None: 

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

356 

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

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

359 self._GenerateTestcase(tc, testsuiteElement) 

360 

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

362 """ 

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

364 

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

366 

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

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

369 """ 

370 testcaseElement = SubElement(parentElement, "testcase") 

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

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

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

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

375 

376 self._GenerateTestcaseChildren(testcase, testcaseElement) 

377 

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

379 """ 

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

381 

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

383 empty for a test case without a message. 

384 

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

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

387 """ 

388 super()._GenerateTestcaseChildren(testcase, testcaseElement) 

389 

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

391 if "message" not in statusElement.attrib: 

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