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
« 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.
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
39from datetime import timedelta
40from pathlib import Path
41from re import fullmatch
42from typing import Optional as Nullable, Type, ClassVar
44from lxml.etree import ElementTree, Element, SubElement, tostring, _Element
45from pyTooling.Decorators import export, InheritDocString, DocStringMergeStrategy
46from pyTooling.Stopwatch import Stopwatch
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
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 """
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 """
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 """
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.
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 )
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.")
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
107 if classname in juTestsuite._testclasses:
108 juClass = juTestsuite._testclasses[classname]
109 else:
110 juClass = Testclass(classname, parent=juTestsuite)
112 juClass.AddTestcase(Testcase.FromTestcase(tc))
114 return juTestsuite
116 def ToTestsuite(self) -> ut_Testsuite:
117 """
118 Convert this test suite to a test suite of the unified test entity data model.
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.
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 )
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 """
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.
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 )
162@export
163class Document(ju_Document):
164 """
165 A document reader and writer for the go-junit-report JUnit XML file format.
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.
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 """
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.
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.
184 .. hint::
186 The time spend for analysis will be made available via property :data:`AnalysisDuration`.
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)
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.
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
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.")
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)
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
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
227 def Convert(self) -> None:
228 """
229 Convert the parsed and validated XML data structure into a JUnit test entity hierarchy.
231 This method converts the root element.
233 .. hint::
235 The time spend for model conversion will be made available via property :data:`ModelConversionDuration`.
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
246 with Stopwatch() as sw:
247 rootElement: _Element = self._xmlDocument.getroot()
249 self._name = self._ConvertName(rootElement, optional=True)
250 self._duration = self._ConvertTime(rootElement, optional=True)
252 for rootNode in rootElement.iterchildren(tag="testsuite"): # type: _Element
253 self._ConvertTestsuite(self, rootNode)
255 self.Aggregate()
257 self._modelConversion = sw.Duration
259 def _ConvertTestsuite(self, parent: TestsuiteSummary, testsuitesNode: _Element) -> None:
260 """
261 Convert the XML data structure of a ``<testsuite>`` to a test suite.
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.
267 This method uses private helper methods provided by the base-class.
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"]
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
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 )
291 self._ConvertTestsuiteChildren(testsuitesNode, newTestsuite)
293 def Generate(self, overwrite: bool = False) -> None:
294 """
295 Generate the internal XML data structure from test suites and test cases.
297 This method generates the XML root element (``<testsuites>``) and recursively calls other generated methods.
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.")
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)
314 self._xmlDocument = ElementTree(rootElement)
316 for testsuite in self._testsuites.values():
317 self._GenerateTestsuite(testsuite, rootElement)
319 def _GenerateTestsuite(self, testsuite: Testsuite, parentElement: _Element) -> None:
320 """
321 Generate the internal XML data structure for a test suite.
323 This method generates the XML element (``<testsuite>``) and recursively calls other generated methods.
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``.
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()}"
357 for testclass in testsuite._testclasses.values():
358 for tc in testclass._testcases.values():
359 self._GenerateTestcase(tc, testsuiteElement)
361 def _GenerateTestcase(self, testcase: Testcase, parentElement: _Element) -> None:
362 """
363 Generate the internal XML data structure for a test case.
365 This method generates the XML element (``<testcase>``) and recursively calls other generated methods.
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}"
376 self._GenerateTestcaseChildren(testcase, testcaseElement)
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.
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.
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)
390 for statusElement in testcaseElement.iterchildren("skipped", "error", "failure"): # type: _Element
391 if "message" not in statusElement.attrib:
392 statusElement.attrib["message"] = ""