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
« 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.
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, 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
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.")
100 elif ts is testsuite:
101 juTestsuite.AddTestcase(Testcase.FromTestcase(tc))
102 continue
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
110 if classname in juTestsuite._testclasses:
111 juClass = juTestsuite._testclasses[classname]
112 else:
113 juClass = Testclass(classname, parent=juTestsuite)
115 juClass.AddTestcase(Testcase.FromTestcase(tc))
117 return juTestsuite
119 def ToTestsuite(self) -> ut_Testsuite:
120 """
121 Convert this test suite to a test suite of the unified test entity data model.
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.
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 )
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 """
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.
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 )
166@export
167class Document(ju_Document):
168 """
169 A document reader and writer for the go-junit-report JUnit XML file format.
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.
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 """
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.
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.
188 .. hint::
190 The time spend for analysis will be made available via property :data:`AnalysisDuration`.
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)
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.
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
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.")
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)
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
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
231 def Convert(self) -> None:
232 """
233 Convert the parsed and validated XML data structure into a JUnit test entity hierarchy.
235 This method converts the root element.
237 .. hint::
239 The time spend for model conversion will be made available via property :data:`ModelConversionDuration`.
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
250 with Stopwatch() as sw:
251 rootElement: _Element = self._xmlDocument.getroot()
253 self._name = self._ConvertName(rootElement, optional=True)
254 self._duration = self._ConvertTime(rootElement, optional=True)
256 for rootNode in rootElement.iterchildren(tag="testsuite"): # type: _Element
257 self._ConvertTestsuite(self, rootNode)
259 self.Aggregate()
261 self._modelConversion = sw.Duration
263 def _ConvertTestsuite(self, parent: TestsuiteSummary, testsuitesNode: _Element) -> None:
264 """
265 Convert the XML data structure of a ``<testsuite>`` to a test suite.
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.
271 This method uses private helper methods provided by the base-class.
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"]
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
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 )
295 self._ConvertTestsuiteChildren(testsuitesNode, newTestsuite)
297 def Generate(self, overwrite: bool = False) -> None:
298 """
299 Generate the internal XML data structure from test suites and test cases.
301 This method generates the XML root element (``<testsuites>``) and recursively calls other generated methods.
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.")
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)
318 self._xmlDocument = ElementTree(rootElement)
320 for testsuite in self._testsuites.values():
321 self._GenerateTestsuite(testsuite, rootElement)
323 def _GenerateTestsuite(self, testsuite: Testsuite, parentElement: _Element) -> None:
324 """
325 Generate the internal XML data structure for a test suite.
327 This method generates the XML element (``<testsuite>``) and recursively calls other generated methods.
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``.
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()}"
364 for tc in testsuite._testcases.values():
365 self._GenerateTestcase(tc, testsuiteElement)
367 for testclass in testsuite._testclasses.values():
368 for tc in testclass._testcases.values():
369 self._GenerateTestcase(tc, testsuiteElement)
371 def _GenerateTestcase(self, testcase: Testcase, parentElement: _Element) -> None:
372 """
373 Generate the internal XML data structure for a test case.
375 This method generates the XML element (``<testcase>``) and recursively calls other generated methods.
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}"
386 self._GenerateTestcaseChildren(testcase, testcaseElement)
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.
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.
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)
400 for statusElement in testcaseElement.iterchildren("skipped", "error", "failure"): # type: _Element
401 if "message" not in statusElement.attrib:
402 statusElement.attrib["message"] = ""