Coverage for pyEDAA/Reports/Unittesting/JUnit/NextestJUnit.py: 93%
194 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 the JUnit XML dialect of cargo-nextest.
34.. seealso::
36 :ref:`UNITTEST/SpecificDataModel/JUnit/Dialect/nextest`
37 |rarr| The dialect's mapping to the unified data model and its known issues.
38"""
39from __future__ import annotations
41from datetime import datetime, timedelta
42from pathlib import Path
43from typing import Optional as Nullable, Iterable, Type, ClassVar
44from uuid import UUID
46from lxml.etree import ElementTree, Element, SubElement, tostring, _Element
47from pyTooling.Common import getFullyQualifiedName
48from pyTooling.Decorators import export, readonly, InheritDocString, DocStringMergeStrategy
49from pyTooling.Stopwatch import Stopwatch
51from pyEDAA.Reports.Unittesting import UnittestError, TestsuiteKind, TestcaseStatus, TestsuiteStatus, \
52 TestsuiteSummary as ut_TestsuiteSummary, Testsuite as ut_Testsuite, \
53 Testcase as ut_Testcase
54from pyEDAA.Reports.Unittesting.JUnit import Testcase as ju_Testcase, Testclass as ju_Testclass, \
55 Testsuite as ju_Testsuite, TestsuiteSummary as ju_TestsuiteSummary, \
56 Document as ju_Document, JUnitReaderMode
59@export
60@InheritDocString(ju_Testcase, DocStringMergeStrategy.BaseLast)
61class Testcase(ju_Testcase):
62 """
63 This is a derived implementation for the cargo-nextest JUnit dialect.
65 Besides the fields of a JUnit test case, it carries the time the test case was started and the number of reruns
66 nextest recorded for it.
67 """
69 _startTime: Nullable[datetime] #: Time when the test case was started.
70 _rerunCount: int #: Number of reruns recorded as ``<flakyFailure>``, ``<rerunFailure>``, etc.
72 def __init__(
73 self,
74 name: str,
75 duration: Nullable[timedelta] = None,
76 status: TestcaseStatus = TestcaseStatus.Unknown,
77 assertionCount: Nullable[int] = None,
78 message: Nullable[str] = None,
79 details: Nullable[str] = None,
80 standardOutput: Nullable[str] = None,
81 standardError: Nullable[str] = None,
82 startTime: Nullable[datetime] = None,
83 rerunCount: int = 0,
84 *,
85 parent: Nullable[Testclass | Testsuite] = None
86 ) -> None:
87 """
88 Initializes the fields of a test case.
90 :param name: Name of the test entity.
91 :param duration: Optional, duration of the entity's execution.
92 :param status: Optional, status of the test case.
93 :param assertionCount: Optional, number of assertions within the test.
94 :param message: Optional, message explaining the test case's status.
95 :param details: Optional, details explaining the test case's status (e.g. a traceback).
96 :param standardOutput: Optional, captured standard output of the test case.
97 :param standardError: Optional, captured standard error of the test case.
98 :param startTime: Optional, time when the test case was started.
99 :param rerunCount: Optional, number of reruns recorded for the test case.
100 :param parent: Optional, reference to the parent test class or test suite.
101 :raises TypeError: If parameter 'startTime' is not a datetime.
102 :raises ValueError: If parameter 'rerunCount' is None.
103 :raises TypeError: If parameter 'rerunCount' is not an integer.
104 :raises ValueError: If parameter 'rerunCount' is negative.
105 """
106 super().__init__(
107 name, duration, status, assertionCount, message, details, standardOutput, standardError, parent=parent
108 )
110 if startTime is not None and not isinstance(startTime, datetime):
111 ex = TypeError("Parameter 'startTime' is not of type 'datetime'.")
112 ex.add_note(f"Got type '{getFullyQualifiedName(startTime)}'.")
113 raise ex
115 if rerunCount is None:
116 raise ValueError("Parameter 'rerunCount' is None.")
117 elif not isinstance(rerunCount, int):
118 ex = TypeError("Parameter 'rerunCount' is not of type 'int'.")
119 ex.add_note(f"Got type '{getFullyQualifiedName(rerunCount)}'.")
120 raise ex
121 elif rerunCount < 0:
122 ex = ValueError("Parameter 'rerunCount' is negative.")
123 ex.add_note(f"Got value '{rerunCount}'.")
124 raise ex
126 self._startTime = startTime
127 self._rerunCount = rerunCount
129 @readonly
130 def StartTime(self) -> Nullable[datetime]:
131 """
132 Read-only property to access the time when the test case was started (:attr:`_startTime`).
134 nextest writes no start time for a skipped test case.
136 :returns: Start time of the test case, or ``None`` if it wasn't recorded.
137 """
138 return self._startTime
140 @readonly
141 def RerunCount(self) -> int:
142 """
143 Read-only property to access the number of reruns nextest recorded for the test case (:attr:`_rerunCount`).
145 A test case passing after a rerun is *flaky*: its status is passed and its rerun count is not zero.
147 :returns: Number of reruns.
148 """
149 return self._rerunCount
151 def Copy(self) -> Testcase:
152 """
153 Copy the test case without its parent.
155 :returns: A copy of the test case, including its start time and rerun count.
156 """
157 testcase = super().Copy()
158 testcase._startTime = self._startTime
159 testcase._rerunCount = self._rerunCount
161 return testcase
163 @classmethod
164 def FromTestcase(cls, testcase: ut_Testcase) -> Testcase:
165 """
166 Convert a test case of the unified test entity data model to the JUnit specific data model's test case object
167 adhering to the cargo-nextest JUnit dialect.
169 :param testcase: Test case from unified data model.
170 :returns: Test case from JUnit specific data model (cargo-nextest JUnit dialect).
171 """
172 juTestcase = super().FromTestcase(testcase)
173 juTestcase._startTime = testcase._startTime
175 return juTestcase
177 def ToTestcase(self) -> ut_Testcase:
178 """
179 Convert this test case to a test case of the unified test entity data model.
181 The start time is kept, the rerun count is lost.
183 :returns: Test case of the unified test entity data model.
184 """
185 testcase = super().ToTestcase()
186 testcase._startTime = self._startTime
188 return testcase
191@export
192@InheritDocString(ju_Testclass, DocStringMergeStrategy.BaseLast)
193class Testclass(ju_Testclass):
194 """
195 This is a derived implementation for the cargo-nextest JUnit dialect.
196 """
199@export
200@InheritDocString(ju_Testsuite, DocStringMergeStrategy.BaseLast)
201class Testsuite(ju_Testsuite):
202 """
203 This is a derived implementation for the cargo-nextest JUnit dialect.
204 """
206 @classmethod
207 def FromTestsuite(cls, testsuite: ut_Testsuite) -> Testsuite:
208 """
209 Convert a test suite of the unified test entity data model to the JUnit specific data model's test suite object
210 adhering to the cargo-nextest JUnit dialect.
212 :param testsuite: Test suite from unified data model.
213 :returns: Test suite from JUnit specific data model (cargo-nextest JUnit dialect).
214 :raises UnittestError: If a test case of the test suite is not part of a hierarchy.
215 """
216 juTestsuite = cls(
217 testsuite._name,
218 hostname=testsuite._hostname,
219 startTime=testsuite._startTime,
220 duration=testsuite._totalDuration,
221 status= testsuite._status,
222 )
224 juTestsuite._tests = testsuite._tests
225 juTestsuite._skipped = testsuite._skipped
226 juTestsuite._errored = testsuite._errored
227 juTestsuite._failed = testsuite._failed
228 juTestsuite._passed = testsuite._passed
230 for tc in testsuite.IterateTestcases():
231 ts = tc._parent
232 if ts is None: 232 ↛ 233line 232 didn't jump to line 233 because the condition on line 232 was never true
233 raise UnittestError(f"Testcase '{tc._name}' is not part of a hierarchy.")
234 elif ts is testsuite:
235 juTestsuite.AddTestcase(Testcase.FromTestcase(tc))
236 continue
238 classname = ts._name
239 ts = ts._parent
240 while ts is not None and ts._kind > TestsuiteKind.Logical:
241 classname = f"{ts._name}.{classname}"
242 ts = ts._parent
244 if classname in juTestsuite._testclasses:
245 juClass = juTestsuite._testclasses[classname]
246 else:
247 juClass = Testclass(classname, parent=juTestsuite)
249 juClass.AddTestcase(Testcase.FromTestcase(tc))
251 return juTestsuite
254@export
255@InheritDocString(ju_TestsuiteSummary, DocStringMergeStrategy.BaseLast)
256class TestsuiteSummary(ju_TestsuiteSummary):
257 """
258 This is a derived implementation for the cargo-nextest JUnit dialect.
259 """
261 @classmethod
262 def FromTestsuiteSummary(cls, testsuiteSummary: ut_TestsuiteSummary) -> TestsuiteSummary:
263 """
264 Convert a test suite summary of the unified test entity data model to the JUnit specific data model's test suite
265 summary object adhering to the cargo-nextest JUnit dialect.
267 :param testsuiteSummary: Test suite summary from unified data model.
268 :returns: Test suite summary from JUnit specific data model (cargo-nextest JUnit dialect).
269 """
270 return cls(
271 testsuiteSummary._name,
272 startTime=testsuiteSummary._startTime,
273 duration=testsuiteSummary._totalDuration,
274 status=testsuiteSummary._status,
275 testsuites=(Testsuite.FromTestsuite(testsuite) for testsuite in testsuiteSummary._testsuites.values())
276 )
279@export
280class Document(ju_Document):
281 """
282 A document reader and writer for the cargo-nextest JUnit XML file format.
284 This class reads, validates and transforms an XML file in the cargo-nextest JUnit format into a JUnit data model. It
285 can then be converted into a unified test entity data model.
287 In reverse, a JUnit data model instance with the specific cargo-nextest JUnit file format can be created from a
288 unified test entity data model. This data model can be written as XML into a file.
289 """
291 _DIALECT: ClassVar[str] = "cargo-nextest + JUnit" #: Name of the dialect in messages.
292 _TESTCASE: ClassVar[Type[Testcase]] = Testcase #: Class of the test cases read.
293 _TESTCLASS: ClassVar[Type[Testclass]] = Testclass #: Class of the test classes read.
294 _TESTSUITE: ClassVar[Type[Testsuite]] = Testsuite #: Class of the test suites read.
296 _runID: Nullable[UUID] #: Run ID of the test run, which wrote the report.
298 def __init__(
299 self,
300 xmlReportFile: Path,
301 analyzeAndConvert: bool = False,
302 readerMode: JUnitReaderMode = JUnitReaderMode.Default,
303 *,
304 name: str = "Unprocessed JUnit XML file",
305 startTime: Nullable[datetime] = None,
306 duration: Nullable[timedelta] = None,
307 status: TestsuiteStatus = TestsuiteStatus.Unknown,
308 testsuites: Nullable[Iterable[Testsuite]] = None,
309 runID: Nullable[UUID] = None
310 ) -> None:
311 """
312 Initializes a cargo-nextest JUnit document.
314 :param xmlReportFile: Path to the XML file.
315 :param analyzeAndConvert: Optional, if true, read, validate and convert the file.
316 :param readerMode: Optional, mode of the JUnit reader.
317 :param name: Optional, name of the test suite summary.
318 :param startTime: Optional, time when the test run was started.
319 :param duration: Optional, duration of the test run.
320 :param status: Optional, overall status of the test run.
321 :param testsuites: Optional, test suites of the summary.
322 :param runID: Optional, run ID of the test run, which wrote the report.
323 """
324 super().__init__(
325 xmlReportFile,
326 False,
327 readerMode,
328 name=name,
329 startTime=startTime,
330 duration=duration,
331 status=status,
332 testsuites=testsuites
333 )
335 self._runID = runID
337 if analyzeAndConvert:
338 self.Analyze()
339 self.Convert()
341 @readonly
342 def RunID(self) -> Nullable[UUID]:
343 """
344 Read-only property to access the run ID of the test run, which wrote the report (:attr:`_runID`).
346 :returns: The run ID, or ``None`` if the report has none.
347 """
348 return self._runID
350 def Analyze(self) -> None:
351 """
352 Analyze the XML file, parse the content into an XML data structure and validate the data structure using an XML
353 schema.
355 .. hint::
357 The time spend for analysis will be made available via property :data:`AnalysisDuration`.
359 The used XML schema definition is specific to the cargo-nextest JUnit dialect.
360 """
361 xmlSchemaFile = "Nextest-JUnit.xsd"
362 self._Analyze(xmlSchemaFile)
364 def Write(self, path: Nullable[Path] = None, overwrite: bool = False, regenerate: bool = False) -> None:
365 """
366 Write the data model as XML into a file adhering to the cargo-nextest dialect.
368 :param path: Optional, path to the XML file, if internal path shouldn't be used.
369 :param overwrite: Optional, if true, overwrite an existing file.
370 :param regenerate: Optional, if true, regenerate the XML structure from data model.
371 :raises UnittestError: If the file cannot be overwritten.
372 :raises UnittestError: If the internal XML data structure wasn't generated. |br|
373 Call ``Document.Generate()`` or ``Document.Write(..., regenerate=True)``.
374 :raises UnittestError: If the file cannot be opened or written.
375 """
376 if path is None:
377 path = self._path
379 if not overwrite and path.exists():
380 raise UnittestError(f"JUnit XML file '{path}' can not be overwritten.") \
381 from FileExistsError(f"File '{path}' already exists.")
383 if regenerate: 383 ↛ 386line 383 didn't jump to line 386 because the condition on line 383 was always true
384 self.Generate(overwrite=True)
386 if self._xmlDocument is None: 386 ↛ 387line 386 didn't jump to line 387 because the condition on line 386 was never true
387 ex = UnittestError("Internal XML document tree is empty and needs to be generated before write is possible.")
388 ex.add_note("Call 'Document.Generate()' or 'Document.Write(..., regenerate=True)'.")
389 raise ex
391 content = tostring(self._xmlDocument, encoding="utf-8", xml_declaration=True, pretty_print=True)
392 try:
393 with path.open("wb") as file:
394 file.write(content)
395 except OSError as ex:
396 raise UnittestError(f"JUnit XML file '{path}' can not be written.") from ex
398 def Convert(self) -> None:
399 """
400 Convert the parsed and validated XML data structure into a JUnit test entity hierarchy.
402 This method converts the root element.
404 .. hint::
406 The time spend for model conversion will be made available via property :data:`ModelConversionDuration`.
408 :raises UnittestError: If XML was not read and parsed before. |br|
409 Call ``Document.Analyze()`` or create the document using
410 ``Document(path, analyzeAndConvert=True)``.
411 """
412 if self._xmlDocument is None: 412 ↛ 413line 412 didn't jump to line 413 because the condition on line 412 was never true
413 ex = UnittestError(f"JUnit XML file '{self._path}' needs to be read and analyzed by an XML parser.")
414 ex.add_note("Call 'Document.Analyze()' or create the document using 'Document(path, analyzeAndConvert=True)'.")
415 raise ex
417 with Stopwatch() as sw:
418 rootElement: _Element = self._xmlDocument.getroot()
420 self._name = self._ConvertName(rootElement, optional=False)
421 self._runID = UUID(rootElement.attrib["uuid"]) if "uuid" in rootElement.attrib else None
422 self._startTime = self._ConvertTimestamp(rootElement, optional=False)
423 self._duration = self._ConvertTime(rootElement, optional=False)
425 for rootNode in rootElement.iterchildren(tag="testsuite"): # type: _Element
426 self._ConvertTestsuite(self, rootNode)
428 self.Aggregate()
430 self._modelConversion = sw.Duration
432 def _ConvertTestsuite(self, parent: TestsuiteSummary, testsuitesNode: _Element) -> None:
433 """
434 Convert the XML data structure of a ``<testsuite>`` to a test suite.
436 A ``<testsuite>`` written by nextest has neither a hostname, nor a start time, nor a duration.
438 :param parent: The test suite summary as a parent element in the test entity hierarchy.
439 :param testsuitesNode: The current XML element node representing a test suite.
440 """
441 newTestsuite = self._TESTSUITE(self._ConvertName(testsuitesNode, optional=False), parent=parent)
443 self._ConvertTestsuiteChildren(testsuitesNode, newTestsuite)
445 def _ConvertTestcase(self, parent: Testsuite, testcaseNode: _Element) -> None:
446 """
447 Convert the XML data structure of a ``<testcase>`` to a test case.
449 Besides the fields of a JUnit test case, the start time (``timestamp``) and the number of reruns
450 (``<flakyFailure>``, ``<flakyError>``, ``<rerunFailure>`` and ``<rerunError>`` elements) are converted.
452 :param parent: The test suite as a parent element in the test entity hierarchy.
453 :param testcaseNode: The current XML element node representing a test case.
454 """
455 className = self._ConvertClassname(testcaseNode)
456 if className == "":
457 testcaseParent = parent
458 else:
459 testcaseParent = self._FindOrCreateTestclass(parent, className)
461 newTestcase = self._TESTCASE(
462 self._ConvertName(testcaseNode, optional=False),
463 self._ConvertTime(testcaseNode, optional=False),
464 startTime=self._ConvertTimestamp(testcaseNode, optional=True),
465 rerunCount=len(tuple(testcaseNode.iterchildren("flakyFailure", "flakyError", "rerunFailure", "rerunError"))),
466 parent=testcaseParent
467 )
469 self._ConvertTestcaseChildren(testcaseNode, newTestcase)
471 def Generate(self, overwrite: bool = False) -> None:
472 """
473 Generate the internal XML data structure from test suites and test cases.
475 This method generates the XML root element (``<testsuites>``) and recursively calls other generated methods.
477 :param overwrite: Optional, overwrite the internal XML data structure.
478 :raises UnittestError: If overwrite is false and the internal XML data structure is not empty.
479 :raises UnittestError: If the test suite summary has no start time.
480 :raises UnittestError: If the test suite summary has no duration.
481 """
482 if not overwrite and self._xmlDocument is not None: 482 ↛ 483line 482 didn't jump to line 483 because the condition on line 482 was never true
483 raise UnittestError("Internal XML document is populated with data.")
485 if self._startTime is None:
486 raise UnittestError(
487 f"The {self._DIALECT} format requires a timestamp on <testsuites>, but the report has none."
488 )
490 if self._duration is None: 490 ↛ 491line 490 didn't jump to line 491 because the condition on line 490 was never true
491 raise UnittestError(f"The {self._DIALECT} format requires a time on <testsuites>, but the report has none.")
493 rootElement = Element("testsuites")
494 rootElement.attrib["name"] = self._name
495 rootElement.attrib["tests"] = str(self._tests)
496 rootElement.attrib["skipped"] = str(self._skipped)
497 rootElement.attrib["failures"] = str(self._failed)
498 rootElement.attrib["errors"] = str(self._errored)
499 if self._runID is not None:
500 rootElement.attrib["uuid"] = str(self._runID)
501 rootElement.attrib["timestamp"] = f"{self._startTime.isoformat()}"
502 rootElement.attrib["time"] = f"{self._duration.total_seconds():.6f}"
504 self._xmlDocument = ElementTree(rootElement)
506 for testsuite in self._testsuites.values():
507 self._GenerateTestsuite(testsuite, rootElement)
509 def _GenerateTestsuite(self, testsuite: Testsuite, parentElement: _Element) -> None:
510 """
511 Generate the internal XML data structure for a test suite.
513 This method generates the XML element (``<testsuite>``) and recursively calls other generated methods. The
514 dialect has no hostname, start time and duration on a ``<testsuite>``, so these are not written.
516 :param testsuite: The test suite to convert to an XML data structures.
517 :param parentElement: The parent XML data structure element, this data structure part will be added to.
518 """
519 testsuiteElement = SubElement(parentElement, "testsuite")
520 testsuiteElement.attrib["name"] = testsuite._name
521 testsuiteElement.attrib["tests"] = str(testsuite._tests)
522 testsuiteElement.attrib["skipped"] = str(testsuite._skipped)
523 testsuiteElement.attrib["errors"] = str(testsuite._errored)
524 testsuiteElement.attrib["failures"] = str(testsuite._failed)
526 for tc in testsuite._testcases.values():
527 self._GenerateTestcase(tc, testsuiteElement)
529 for testclass in testsuite._testclasses.values():
530 for tc in testclass._testcases.values():
531 self._GenerateTestcase(tc, testsuiteElement)
533 def _GenerateTestcase(self, testcase: Testcase, parentElement: _Element) -> None:
534 """
535 Generate the internal XML data structure for a test case.
537 This method generates the XML element (``<testcase>``) and recursively calls other generated methods. Reruns are
538 not written.
540 :param testcase: The test case to convert to an XML data structures.
541 :param parentElement: The parent XML data structure element, this data structure part will be added to.
542 :raises UnittestError: If the test case has no duration.
543 """
544 if testcase._duration is None: 544 ↛ 545line 544 didn't jump to line 545 because the condition on line 544 was never true
545 raise UnittestError(
546 f"The {self._DIALECT} format requires a time on <testcase>, but test case '{testcase._name}' has none."
547 )
549 testcaseElement = SubElement(parentElement, "testcase")
550 testcaseElement.attrib["name"] = testcase._name
551 testcaseElement.attrib["classname"] = testcase.Classname
552 if testcase._startTime is not None:
553 testcaseElement.attrib["timestamp"] = f"{testcase._startTime.isoformat()}"
554 testcaseElement.attrib["time"] = f"{testcase._duration.total_seconds():.6f}"
556 self._GenerateTestcaseChildren(testcase, testcaseElement)