Coverage for pyEDAA/Reports/Unittesting/pyTooling.py: 93%
120 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 for pyTooling's XML test report format.
34`pyTooling <https://github.com/pyTooling/pyTooling>`__ can write a test report (pytest option ``--pytooling-xml``) in a
35format of its own, which carries two things JUnit XML can't express: test suites nest, and every test suite and test
36case can carry a title, a summary and a description besides its name.
38The format's version - a :class:`FormatVersion` - is the version of its XML schema, which a report names in its
39``xsi:noNamespaceSchemaLocation`` attribute (e.g. ``TestReport-v0.1.xsd``). A report is validated against that schema
40and converted into the unified test entity hierarchy of :mod:`pyEDAA.Reports.Unittesting`.
42.. rubric:: Example
44.. code-block:: Python
46 from pathlib import Path
47 from pyEDAA.Reports.Unittesting.pyTooling import Document
49 document = Document(Path("report/unit/TestReport.xml"), analyzeAndConvert=True)
50 for testcase in document.IterateTestcases():
51 print(f"{testcase.Name}: {testcase.Status.name} - {testcase.Title}")
52"""
53from __future__ import annotations
55from datetime import datetime, timedelta
56from io import BytesIO
57from pathlib import Path
58from typing import Dict, Optional as Nullable
60from lxml.etree import XMLParser, XMLSchema, XMLSchemaParseError, XMLSyntaxError, parse
61from lxml.etree import _Element, _ElementTree
62from pyTooling import Resources as pyToolingResources
63from pyTooling.Common import getResourceFile, StringEnum
64from pyTooling.Decorators import export, readonly
65from pyTooling.Exceptions import ToolingException
66from pyTooling.Stopwatch import Stopwatch
68from pyEDAA.Reports.Unittesting import UnittestError, TestcaseStatus, TestsuiteKind
69from pyEDAA.Reports.Unittesting import Document as ut_Document, TestsuiteSummary, Testsuite, Testcase
72__all__ = ["XML_SCHEMA_INSTANCE_NAMESPACE", "SCHEMA_FILES", "STATUS_MAP"]
74XML_SCHEMA_INSTANCE_NAMESPACE = "http://www.w3.org/2001/XMLSchema-instance" #: Namespace of the ``xsi:*`` attributes.
77@export
78class FormatVersion(StringEnum):
79 """
80 Version of pyTooling's test report format, as the XML schema a report names states it.
81 """
83 Version0_1 = "0.1" #: Format version 0.1, defined by :file:`TestReport-v0.1.xsd`.
86SCHEMA_FILES: Dict[str, FormatVersion] = {
87 "TestReport-v0.1.xsd": FormatVersion.Version0_1,
88} #: Supported schema files (as named by ``xsi:noNamespaceSchemaLocation``) and the format version they define.
90STATUS_MAP: Dict[str, TestcaseStatus] = {
91 "passed": TestcaseStatus.Passed,
92 "failed": TestcaseStatus.Failed,
93 "errored": TestcaseStatus.Errored,
94 "skipped": TestcaseStatus.Skipped,
95 "expectedToFail": TestcaseStatus.ExpectedFailed,
96 "unexpectedlyPassed": TestcaseStatus.UnexpectedPassed,
97} #: Mapping of a ``<Testcase>``'s ``status`` attribute to a test case status.
100@export
101class Document(TestsuiteSummary, ut_Document):
102 """
103 A document reader for pyTooling's XML test report format.
105 The root element ``<TestReport>`` becomes the test suite summary, every nested ``<Testsuite>`` a test suite and
106 every ``<Testcase>`` a test case. The optional ``<Title>``, ``<Summary>`` and ``<Description>`` elements become the
107 entities' :data:`~pyEDAA.Reports.Unittesting.Base.Title`, :data:`~pyEDAA.Reports.Unittesting.Base.Summary` and
108 :data:`~pyEDAA.Reports.Unittesting.Base.Description`. A test case's ``<Message>`` (why it didn't pass) becomes its
109 :data:`~pyEDAA.Reports.Unittesting.TestcaseOutputMixin.Message`, its ``nodeID`` attribute (the test runner's
110 identifier) the key-value pair ``"nodeID"``.
111 """
113 _xmlDocument: Nullable[_ElementTree] #: Parsed and validated XML document.
114 _schemaVersion: Nullable[FormatVersion] #: Format version named by the report's schema location.
116 def __init__(self, xmlReportFile: Path, analyzeAndConvert: bool = False) -> None:
117 """
118 Initializes the document reader.
120 :param xmlReportFile: Path to the XML report file.
121 :param analyzeAndConvert: Optional, if true, analyze (parse and validate) the file and convert its content.
122 """
123 super().__init__("Unprocessed pyTooling test report")
125 self._xmlDocument = None
126 self._schemaVersion = None
128 ut_Document.__init__(self, xmlReportFile, analyzeAndConvert)
130 @readonly
131 def SchemaVersion(self) -> FormatVersion:
132 """
133 Read-only property to access the report format's version (:attr:`_schemaVersion`).
135 The version is named by the schema the report points at - e.g. ``0.1`` by :file:`TestReport-v0.1.xsd` -, so it is
136 known once the file was analyzed.
138 :returns: The format version.
139 :raises UnittestError: If the file wasn't analyzed yet.
140 """
141 if self._schemaVersion is None:
142 ex = UnittestError(f"pyTooling test report file '{self._path}' wasn't analyzed yet.")
143 ex.add_note("Call 'Document.Analyze()' or create the document using 'Document(path, analyzeAndConvert=True)'.")
144 raise ex
146 return self._schemaVersion
148 def Analyze(self) -> None:
149 """
150 Analyze the XML file: parse it, determine the format version and validate it against the version's XML schema.
152 .. hint::
154 The time spend for analysis will be made available via property :data:`AnalysisDuration`.
156 :raises UnittestError: If the file doesn't exist.
157 :raises UnittestError: If the file can't be read.
158 :raises UnittestError: If the file isn't well-formed XML.
159 :raises UnittestError: If the root element isn't ``<TestReport>``.
160 :raises UnittestError: If the root element has no ``xsi:noNamespaceSchemaLocation`` attribute.
161 :raises UnittestError: If the format version named by the schema location is unknown.
162 :raises UnittestError: If the XML schema can't be located or parsed.
163 :raises UnittestError: If the file isn't valid according to the XML schema.
164 """
165 if not self._path.exists():
166 raise UnittestError(f"pyTooling test report file '{self._path}' does not exist.") \
167 from FileNotFoundError(f"File '{self._path}' not found.")
169 with Stopwatch() as sw:
170 try:
171 content = self._path.read_bytes()
172 except OSError as ex:
173 raise UnittestError(f"Couldn't read pyTooling test report file '{self._path}'.") from ex
175 try:
176 xmlDocument = parse(BytesIO(content), XMLParser(ns_clean=True), base_url=str(self._path))
177 except XMLSyntaxError as ex:
178 raise UnittestError(f"XML syntax error in pyTooling test report file '{self._path}'.") from ex
180 rootElement: _Element = xmlDocument.getroot()
181 if rootElement.tag != "TestReport":
182 ex = UnittestError(f"Root element of '{self._path}' is not '<TestReport>'.")
183 ex.add_note(f"Got root element '<{rootElement.tag}>'.")
184 raise ex
186 schemaLocation = rootElement.attrib.get(f"{{{XML_SCHEMA_INSTANCE_NAMESPACE}}}noNamespaceSchemaLocation", None)
187 if schemaLocation is None:
188 raise UnittestError(f"Root element of '{self._path}' has no 'xsi:noNamespaceSchemaLocation' attribute.")
190 schemaFile = schemaLocation.replace("\\", "/").rsplit("/", 1)[-1]
191 try:
192 schemaVersion = SCHEMA_FILES[schemaFile]
193 except KeyError:
194 ex = UnittestError(f"Unsupported pyTooling test report format '{schemaLocation}' in '{self._path}'.")
195 ex.add_note(f"Supported schemas: {', '.join(SCHEMA_FILES)}")
196 raise ex from None
198 try:
199 schemaResourceFile = getResourceFile(pyToolingResources, schemaFile)
200 except ToolingException as ex:
201 raise UnittestError(f"Couldn't locate XML Schema '{schemaFile}' in pyTooling's package resources.") from ex
203 try:
204 xmlSchema = XMLSchema(parse(schemaResourceFile, XMLParser(ns_clean=True)))
205 except (XMLSyntaxError, XMLSchemaParseError) as ex:
206 raise UnittestError(f"Error while parsing XML Schema '{schemaFile}'.") from ex
208 if not xmlSchema.validate(xmlDocument):
209 ex = UnittestError(f"Validation error for '{self._path}' using XSD schema '{schemaFile}'.")
210 for logEntry in xmlSchema.error_log:
211 ex.add_note(str(logEntry))
212 raise ex
214 self._xmlDocument = xmlDocument
215 self._schemaVersion = schemaVersion
217 self._analysisDuration = sw.Duration
219 def Convert(self) -> None:
220 """
221 Convert the parsed and validated XML data structure into a test entity hierarchy.
223 The test suite summary is named after the report file (without extension). Afterwards, the hierarchy is
224 aggregated, so all counters and states are computed.
226 .. hint::
228 The time spend for model conversion will be made available via property :data:`ModelConversionDuration`.
230 :raises UnittestError: If the XML file was not analyzed before. |br|
231 Call 'Document.Analyze()' or create the document using
232 'Document(path, analyzeAndConvert=True)'.
233 """
234 if self._xmlDocument is None: 234 ↛ 235line 234 didn't jump to line 235 because the condition on line 234 was never true
235 ex = UnittestError(f"pyTooling test report file '{self._path}' needs to be read and analyzed by an XML parser.")
236 ex.add_note("Call 'Document.Analyze()' or create the document using 'Document(path, analyzeAndConvert=True)'.")
237 raise ex
239 with Stopwatch() as sw:
240 rootElement: _Element = self._xmlDocument.getroot()
242 self._name = self._path.stem
243 if (timestamp := rootElement.attrib.get("timestamp", None)) is not None:
244 self._startTime = datetime.fromisoformat(timestamp)
245 self._totalDuration = self._ConvertDuration(rootElement)
246 self._ConvertTexts(rootElement, self)
248 for element in rootElement.iterchildren(tag="Testsuite"): # type: _Element
249 self._ConvertTestsuite(element, self)
251 self.Aggregate()
253 self._modelConversion = sw.Duration
255 def _ConvertTestsuite(self, testsuiteElement: _Element, parent: TestsuiteSummary | Testsuite) -> None:
256 """
257 Convert a ``<Testsuite>`` element and its children to a test suite.
259 :param testsuiteElement: The XML element node representing a test suite.
260 :param parent: The parent test entity.
261 """
262 testsuite = Testsuite(
263 testsuiteElement.attrib["name"],
264 kind=TestsuiteKind.Logical,
265 totalDuration=self._ConvertDuration(testsuiteElement),
266 parent=parent
267 )
268 self._ConvertTexts(testsuiteElement, testsuite)
270 for element in testsuiteElement.iterchildren(tag="Testsuite"): # type: _Element
271 self._ConvertTestsuite(element, testsuite)
273 for element in testsuiteElement.iterchildren(tag="Testcase"): # type: _Element
274 self._ConvertTestcase(element, testsuite)
276 def _ConvertTestcase(self, testcaseElement: _Element, parent: Testsuite) -> None:
277 """
278 Convert a ``<Testcase>`` element and its children to a test case.
280 :param testcaseElement: The XML element node representing a test case.
281 :param parent: The parent test suite.
282 """
283 messageElement = testcaseElement.find("Message")
285 testcase = Testcase(
286 testcaseElement.attrib["name"],
287 totalDuration=self._ConvertDuration(testcaseElement),
288 status=STATUS_MAP[testcaseElement.attrib["status"]],
289 message=None if messageElement is None else messageElement.text,
290 parent=parent
291 )
292 self._ConvertTexts(testcaseElement, testcase)
294 if (nodeID := testcaseElement.attrib.get("nodeID", None)) is not None:
295 testcase["nodeID"] = nodeID
297 def _ConvertDuration(self, element: _Element) -> Nullable[timedelta]:
298 """
299 Convert the ``duration`` attribute (in seconds) of an XML element node.
301 :param element: The XML element node with an optional ``duration`` attribute.
302 :returns: The duration, or ``None`` if the attribute is absent.
303 """
304 if (duration := element.attrib.get("duration", None)) is None:
305 return None
307 return timedelta(seconds=float(duration))
309 def _ConvertTexts(self, element: _Element, entity: TestsuiteSummary | Testsuite | Testcase) -> None:
310 """
311 Convert the ``<Title>``, ``<Summary>`` and ``<Description>`` child elements of an XML element node.
313 :param element: The XML element node with optional ``<Title>``, ``<Summary>`` and ``<Description>`` children.
314 :param entity: The test entity to update.
315 """
316 if (titleElement := element.find("Title")) is not None:
317 entity._title = titleElement.text
319 if (summaryElement := element.find("Summary")) is not None:
320 entity._summary = summaryElement.text
322 if (descriptionElement := element.find("Description")) is not None:
323 entity._description = descriptionElement.text