Coverage for pyEDAA/Reports/Unittesting/JUnit/NextestJUnit.py: 93%
192 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 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
52from pyEDAA.Reports.Unittesting import TestsuiteSummary as ut_TestsuiteSummary, Testsuite as ut_Testsuite
53from pyEDAA.Reports.Unittesting import Testcase as ut_Testcase
54from pyEDAA.Reports.Unittesting.JUnit import Testcase as ju_Testcase, Testclass as ju_Testclass
55from pyEDAA.Reports.Unittesting.JUnit import Testsuite as ju_Testsuite
56from pyEDAA.Reports.Unittesting.JUnit import TestsuiteSummary as ju_TestsuiteSummary, Document as ju_Document
57from pyEDAA.Reports.Unittesting.JUnit import JUnitReaderMode
60@export
61@InheritDocString(ju_Testcase, DocStringMergeStrategy.BaseLast)
62class Testcase(ju_Testcase):
63 """
64 This is a derived implementation for the cargo-nextest JUnit dialect.
66 Besides the fields of a JUnit test case, it carries the time the test case was started and the number of reruns
67 nextest recorded for it.
68 """
70 _startTime: Nullable[datetime] #: Time when the test case was started.
71 _rerunCount: int #: Number of reruns recorded as ``<flakyFailure>``, ``<rerunFailure>``, etc.
73 def __init__(
74 self,
75 name: str,
76 duration: Nullable[timedelta] = None,
77 status: TestcaseStatus = TestcaseStatus.Unknown,
78 assertionCount: Nullable[int] = None,
79 message: Nullable[str] = None,
80 details: Nullable[str] = None,
81 standardOutput: Nullable[str] = None,
82 standardError: Nullable[str] = None,
83 startTime: Nullable[datetime] = None,
84 rerunCount: int = 0,
85 *,
86 parent: Nullable[Testclass] = None
87 ) -> None:
88 """
89 Initializes the fields of a test case.
91 :param name: Name of the test entity.
92 :param duration: Optional, duration of the entity's execution.
93 :param status: Optional, status of the test case.
94 :param assertionCount: Optional, number of assertions within the test.
95 :param message: Optional, message explaining the test case's status.
96 :param details: Optional, details explaining the test case's status (e.g. a traceback).
97 :param standardOutput: Optional, captured standard output of the test case.
98 :param standardError: Optional, captured standard error of the test case.
99 :param startTime: Optional, time when the test case was started.
100 :param rerunCount: Optional, number of reruns recorded for the test case.
101 :param parent: Optional, reference to the parent test class.
102 :raises TypeError: If parameter 'startTime' is not a datetime.
103 :raises ValueError: If parameter 'rerunCount' is None.
104 :raises TypeError: If parameter 'rerunCount' is not an integer.
105 :raises ValueError: If parameter 'rerunCount' is negative.
106 """
107 super().__init__(
108 name, duration, status, assertionCount, message, details, standardOutput, standardError, parent=parent
109 )
111 if startTime is not None and not isinstance(startTime, datetime):
112 ex = TypeError("Parameter 'startTime' is not of type 'datetime'.")
113 ex.add_note(f"Got type '{getFullyQualifiedName(startTime)}'.")
114 raise ex
116 if rerunCount is None:
117 raise ValueError("Parameter 'rerunCount' is None.")
118 elif not isinstance(rerunCount, int):
119 ex = TypeError("Parameter 'rerunCount' is not of type 'int'.")
120 ex.add_note(f"Got type '{getFullyQualifiedName(rerunCount)}'.")
121 raise ex
122 elif rerunCount < 0:
123 ex = ValueError("Parameter 'rerunCount' is negative.")
124 ex.add_note(f"Got value '{rerunCount}'.")
125 raise ex
127 self._startTime = startTime
128 self._rerunCount = rerunCount
130 @readonly
131 def StartTime(self) -> Nullable[datetime]:
132 """
133 Read-only property to access the time when the test case was started (:attr:`_startTime`).
135 nextest writes no start time for a skipped test case.
137 :returns: Start time of the test case, or ``None`` if it wasn't recorded.
138 """
139 return self._startTime
141 @readonly
142 def RerunCount(self) -> int:
143 """
144 Read-only property to access the number of reruns nextest recorded for the test case (:attr:`_rerunCount`).
146 A test case passing after a rerun is *flaky*: its status is passed and its rerun count is not zero.
148 :returns: Number of reruns.
149 """
150 return self._rerunCount
152 def Copy(self) -> Testcase:
153 """
154 Copy the test case without its parent.
156 :returns: A copy of the test case, including its start time and rerun count.
157 """
158 testcase = super().Copy()
159 testcase._startTime = self._startTime
160 testcase._rerunCount = self._rerunCount
162 return testcase
164 @classmethod
165 def FromTestcase(cls, testcase: ut_Testcase) -> Testcase:
166 """
167 Convert a test case of the unified test entity data model to the JUnit specific data model's test case object
168 adhering to the cargo-nextest JUnit dialect.
170 :param testcase: Test case from unified data model.
171 :returns: Test case from JUnit specific data model (cargo-nextest JUnit dialect).
172 """
173 juTestcase = super().FromTestcase(testcase)
174 juTestcase._startTime = testcase._startTime
176 return juTestcase
178 def ToTestcase(self) -> ut_Testcase:
179 """
180 Convert this test case to a test case of the unified test entity data model.
182 The start time is kept, the rerun count is lost.
184 :returns: Test case of the unified test entity data model.
185 """
186 testcase = super().ToTestcase()
187 testcase._startTime = self._startTime
189 return testcase
192@export
193@InheritDocString(ju_Testclass, DocStringMergeStrategy.BaseLast)
194class Testclass(ju_Testclass):
195 """
196 This is a derived implementation for the cargo-nextest JUnit dialect.
197 """
200@export
201@InheritDocString(ju_Testsuite, DocStringMergeStrategy.BaseLast)
202class Testsuite(ju_Testsuite):
203 """
204 This is a derived implementation for the cargo-nextest JUnit dialect.
205 """
207 @classmethod
208 def FromTestsuite(cls, testsuite: ut_Testsuite) -> Testsuite:
209 """
210 Convert a test suite of the unified test entity data model to the JUnit specific data model's test suite object
211 adhering to the cargo-nextest JUnit dialect.
213 :param testsuite: Test suite from unified data model.
214 :returns: Test suite from JUnit specific data model (cargo-nextest JUnit dialect).
215 :raises UnittestError: If a test case of the test suite is not part of a hierarchy.
216 """
217 juTestsuite = cls(
218 testsuite._name,
219 hostname=testsuite._hostname,
220 startTime=testsuite._startTime,
221 duration=testsuite._totalDuration,
222 status= testsuite._status,
223 )
225 juTestsuite._tests = testsuite._tests
226 juTestsuite._skipped = testsuite._skipped
227 juTestsuite._errored = testsuite._errored
228 juTestsuite._failed = testsuite._failed
229 juTestsuite._passed = testsuite._passed
231 for tc in testsuite.IterateTestcases():
232 ts = tc._parent
233 if ts is None: 233 ↛ 234line 233 didn't jump to line 234 because the condition on line 233 was never true
234 raise UnittestError(f"Testcase '{tc._name}' is not part of a hierarchy.")
236 classname = ts._name
237 ts = ts._parent
238 while ts is not None and ts._kind > TestsuiteKind.Logical:
239 classname = f"{ts._name}.{classname}"
240 ts = ts._parent
242 if classname in juTestsuite._testclasses:
243 juClass = juTestsuite._testclasses[classname]
244 else:
245 juClass = Testclass(classname, parent=juTestsuite)
247 juClass.AddTestcase(Testcase.FromTestcase(tc))
249 return juTestsuite
252@export
253@InheritDocString(ju_TestsuiteSummary, DocStringMergeStrategy.BaseLast)
254class TestsuiteSummary(ju_TestsuiteSummary):
255 """
256 This is a derived implementation for the cargo-nextest JUnit dialect.
257 """
259 @classmethod
260 def FromTestsuiteSummary(cls, testsuiteSummary: ut_TestsuiteSummary) -> TestsuiteSummary:
261 """
262 Convert a test suite summary of the unified test entity data model to the JUnit specific data model's test suite
263 summary object adhering to the cargo-nextest JUnit dialect.
265 :param testsuiteSummary: Test suite summary from unified data model.
266 :returns: Test suite summary from JUnit specific data model (cargo-nextest JUnit dialect).
267 """
268 return cls(
269 testsuiteSummary._name,
270 startTime=testsuiteSummary._startTime,
271 duration=testsuiteSummary._totalDuration,
272 status=testsuiteSummary._status,
273 testsuites=(Testsuite.FromTestsuite(testsuite) for testsuite in testsuiteSummary._testsuites.values())
274 )
277@export
278class Document(ju_Document):
279 """
280 A document reader and writer for the cargo-nextest JUnit XML file format.
282 This class reads, validates and transforms an XML file in the cargo-nextest JUnit format into a JUnit data model. It
283 can then be converted into a unified test entity data model.
285 In reverse, a JUnit data model instance with the specific cargo-nextest JUnit file format can be created from a
286 unified test entity data model. This data model can be written as XML into a file.
287 """
289 _DIALECT: ClassVar[str] = "cargo-nextest + JUnit" #: Name of the dialect in messages.
290 _TESTCASE: ClassVar[Type[Testcase]] = Testcase #: Class of the test cases read.
291 _TESTCLASS: ClassVar[Type[Testclass]] = Testclass #: Class of the test classes read.
292 _TESTSUITE: ClassVar[Type[Testsuite]] = Testsuite #: Class of the test suites read.
294 _runID: Nullable[UUID] #: Run ID of the test run, which wrote the report.
296 def __init__(
297 self,
298 xmlReportFile: Path,
299 analyzeAndConvert: bool = False,
300 readerMode: JUnitReaderMode = JUnitReaderMode.Default,
301 *,
302 name: str = "Unprocessed JUnit XML file",
303 startTime: Nullable[datetime] = None,
304 duration: Nullable[timedelta] = None,
305 status: TestsuiteStatus = TestsuiteStatus.Unknown,
306 testsuites: Nullable[Iterable[Testsuite]] = None,
307 runID: Nullable[UUID] = None
308 ) -> None:
309 """
310 Initializes a cargo-nextest JUnit document.
312 :param xmlReportFile: Path to the XML file.
313 :param analyzeAndConvert: Optional, if true, read, validate and convert the file.
314 :param readerMode: Optional, mode of the JUnit reader.
315 :param name: Optional, name of the test suite summary.
316 :param startTime: Optional, time when the test run was started.
317 :param duration: Optional, duration of the test run.
318 :param status: Optional, overall status of the test run.
319 :param testsuites: Optional, test suites of the summary.
320 :param runID: Optional, run ID of the test run, which wrote the report.
321 """
322 super().__init__(
323 xmlReportFile,
324 False,
325 readerMode,
326 name=name,
327 startTime=startTime,
328 duration=duration,
329 status=status,
330 testsuites=testsuites
331 )
333 self._runID = runID
335 if analyzeAndConvert:
336 self.Analyze()
337 self.Convert()
339 @readonly
340 def RunID(self) -> Nullable[UUID]:
341 """
342 Read-only property to access the run ID of the test run, which wrote the report (:attr:`_runID`).
344 :returns: The run ID, or ``None`` if the report has none.
345 """
346 return self._runID
348 def Analyze(self) -> None:
349 """
350 Analyze the XML file, parse the content into an XML data structure and validate the data structure using an XML
351 schema.
353 .. hint::
355 The time spend for analysis will be made available via property :data:`AnalysisDuration`.
357 The used XML schema definition is specific to the cargo-nextest JUnit dialect.
358 """
359 xmlSchemaFile = "Nextest-JUnit.xsd"
360 self._Analyze(xmlSchemaFile)
362 def Write(self, path: Nullable[Path] = None, overwrite: bool = False, regenerate: bool = False) -> None:
363 """
364 Write the data model as XML into a file adhering to the cargo-nextest dialect.
366 :param path: Optional, path to the XML file, if internal path shouldn't be used.
367 :param overwrite: Optional, if true, overwrite an existing file.
368 :param regenerate: Optional, if true, regenerate the XML structure from data model.
369 :raises UnittestError: If the file cannot be overwritten.
370 :raises UnittestError: If the internal XML data structure wasn't generated. |br|
371 Call ``Document.Generate()`` or ``Document.Write(..., regenerate=True)``.
372 :raises UnittestError: If the file cannot be opened or written.
373 """
374 if path is None:
375 path = self._path
377 if not overwrite and path.exists():
378 raise UnittestError(f"JUnit XML file '{path}' can not be overwritten.") \
379 from FileExistsError(f"File '{path}' already exists.")
381 if regenerate: 381 ↛ 384line 381 didn't jump to line 384 because the condition on line 381 was always true
382 self.Generate(overwrite=True)
384 if self._xmlDocument is None: 384 ↛ 385line 384 didn't jump to line 385 because the condition on line 384 was never true
385 ex = UnittestError("Internal XML document tree is empty and needs to be generated before write is possible.")
386 ex.add_note("Call 'Document.Generate()' or 'Document.Write(..., regenerate=True)'.")
387 raise ex
389 content = tostring(self._xmlDocument, encoding="utf-8", xml_declaration=True, pretty_print=True)
390 try:
391 with path.open("wb") as file:
392 file.write(content)
393 except OSError as ex:
394 raise UnittestError(f"JUnit XML file '{path}' can not be written.") from ex
396 def Convert(self) -> None:
397 """
398 Convert the parsed and validated XML data structure into a JUnit test entity hierarchy.
400 This method converts the root element.
402 .. hint::
404 The time spend for model conversion will be made available via property :data:`ModelConversionDuration`.
406 :raises UnittestError: If XML was not read and parsed before. |br|
407 Call ``Document.Analyze()`` or create the document using
408 ``Document(path, analyzeAndConvert=True)``.
409 """
410 if self._xmlDocument is None: 410 ↛ 411line 410 didn't jump to line 411 because the condition on line 410 was never true
411 ex = UnittestError(f"JUnit XML file '{self._path}' needs to be read and analyzed by an XML parser.")
412 ex.add_note("Call 'Document.Analyze()' or create the document using 'Document(path, analyzeAndConvert=True)'.")
413 raise ex
415 with Stopwatch() as sw:
416 rootElement: _Element = self._xmlDocument.getroot()
418 self._name = self._ConvertName(rootElement, optional=False)
419 self._runID = UUID(rootElement.attrib["uuid"]) if "uuid" in rootElement.attrib else None
420 self._startTime = self._ConvertTimestamp(rootElement, optional=False)
421 self._duration = self._ConvertTime(rootElement, optional=False)
423 for rootNode in rootElement.iterchildren(tag="testsuite"): # type: _Element
424 self._ConvertTestsuite(self, rootNode)
426 self.Aggregate()
428 self._modelConversion = sw.Duration
430 def _ConvertTestsuite(self, parent: TestsuiteSummary, testsuitesNode: _Element) -> None:
431 """
432 Convert the XML data structure of a ``<testsuite>`` to a test suite.
434 A ``<testsuite>`` written by nextest has neither a hostname, nor a start time, nor a duration.
436 :param parent: The test suite summary as a parent element in the test entity hierarchy.
437 :param testsuitesNode: The current XML element node representing a test suite.
438 """
439 newTestsuite = self._TESTSUITE(self._ConvertName(testsuitesNode, optional=False), parent=parent)
441 self._ConvertTestsuiteChildren(testsuitesNode, newTestsuite)
443 def _ConvertTestcase(self, parent: Testsuite, testcaseNode: _Element) -> None:
444 """
445 Convert the XML data structure of a ``<testcase>`` to a test case.
447 Besides the fields of a JUnit test case, the start time (``timestamp``) and the number of reruns
448 (``<flakyFailure>``, ``<flakyError>``, ``<rerunFailure>`` and ``<rerunError>`` elements) are converted.
450 :param parent: The test suite as a parent element in the test entity hierarchy.
451 :param testcaseNode: The current XML element node representing a test case.
452 """
453 className = self._ConvertClassname(testcaseNode)
454 testclass = self._FindOrCreateTestclass(parent, className)
456 newTestcase = self._TESTCASE(
457 self._ConvertName(testcaseNode, optional=False),
458 self._ConvertTime(testcaseNode, optional=False),
459 startTime=self._ConvertTimestamp(testcaseNode, optional=True),
460 rerunCount=len(tuple(testcaseNode.iterchildren("flakyFailure", "flakyError", "rerunFailure", "rerunError"))),
461 parent=testclass
462 )
464 self._ConvertTestcaseChildren(testcaseNode, newTestcase)
466 def Generate(self, overwrite: bool = False) -> None:
467 """
468 Generate the internal XML data structure from test suites and test cases.
470 This method generates the XML root element (``<testsuites>``) and recursively calls other generated methods.
472 :param overwrite: Optional, overwrite the internal XML data structure.
473 :raises UnittestError: If overwrite is false and the internal XML data structure is not empty.
474 :raises UnittestError: If the test suite summary has no start time.
475 :raises UnittestError: If the test suite summary has no duration.
476 """
477 if not overwrite and self._xmlDocument is not None: 477 ↛ 478line 477 didn't jump to line 478 because the condition on line 477 was never true
478 raise UnittestError("Internal XML document is populated with data.")
480 if self._startTime is None:
481 raise UnittestError(
482 f"The {self._DIALECT} format requires a timestamp on <testsuites>, but the report has none."
483 )
485 if self._duration is None: 485 ↛ 486line 485 didn't jump to line 486 because the condition on line 485 was never true
486 raise UnittestError(f"The {self._DIALECT} format requires a time on <testsuites>, but the report has none.")
488 rootElement = Element("testsuites")
489 rootElement.attrib["name"] = self._name
490 rootElement.attrib["tests"] = str(self._tests)
491 rootElement.attrib["skipped"] = str(self._skipped)
492 rootElement.attrib["failures"] = str(self._failed)
493 rootElement.attrib["errors"] = str(self._errored)
494 if self._runID is not None:
495 rootElement.attrib["uuid"] = str(self._runID)
496 rootElement.attrib["timestamp"] = f"{self._startTime.isoformat()}"
497 rootElement.attrib["time"] = f"{self._duration.total_seconds():.6f}"
499 self._xmlDocument = ElementTree(rootElement)
501 for testsuite in self._testsuites.values():
502 self._GenerateTestsuite(testsuite, rootElement)
504 def _GenerateTestsuite(self, testsuite: Testsuite, parentElement: _Element) -> None:
505 """
506 Generate the internal XML data structure for a test suite.
508 This method generates the XML element (``<testsuite>``) and recursively calls other generated methods. The
509 dialect has no hostname, start time and duration on a ``<testsuite>``, so these are not written.
511 :param testsuite: The test suite to convert to an XML data structures.
512 :param parentElement: The parent XML data structure element, this data structure part will be added to.
513 """
514 testsuiteElement = SubElement(parentElement, "testsuite")
515 testsuiteElement.attrib["name"] = testsuite._name
516 testsuiteElement.attrib["tests"] = str(testsuite._tests)
517 testsuiteElement.attrib["skipped"] = str(testsuite._skipped)
518 testsuiteElement.attrib["errors"] = str(testsuite._errored)
519 testsuiteElement.attrib["failures"] = str(testsuite._failed)
521 for testclass in testsuite._testclasses.values():
522 for tc in testclass._testcases.values():
523 self._GenerateTestcase(tc, testsuiteElement)
525 def _GenerateTestcase(self, testcase: Testcase, parentElement: _Element) -> None:
526 """
527 Generate the internal XML data structure for a test case.
529 This method generates the XML element (``<testcase>``) and recursively calls other generated methods. Reruns are
530 not written.
532 :param testcase: The test case to convert to an XML data structures.
533 :param parentElement: The parent XML data structure element, this data structure part will be added to.
534 :raises UnittestError: If the test case has no duration.
535 """
536 if testcase._duration is None: 536 ↛ 537line 536 didn't jump to line 537 because the condition on line 536 was never true
537 raise UnittestError(
538 f"The {self._DIALECT} format requires a time on <testcase>, but test case '{testcase._name}' has none."
539 )
541 testcaseElement = SubElement(parentElement, "testcase")
542 testcaseElement.attrib["name"] = testcase._name
543 testcaseElement.attrib["classname"] = testcase.Classname
544 if testcase._startTime is not None:
545 testcaseElement.attrib["timestamp"] = f"{testcase._startTime.isoformat()}"
546 testcaseElement.attrib["time"] = f"{testcase._duration.total_seconds():.6f}"
548 self._GenerateTestcaseChildren(testcase, testcaseElement)