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

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. 

33 

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. 

37 

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`. 

41 

42.. rubric:: Example 

43 

44.. code-block:: Python 

45 

46 from pathlib import Path 

47 from pyEDAA.Reports.Unittesting.pyTooling import Document 

48 

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 

54 

55from datetime import datetime, timedelta 

56from io import BytesIO 

57from pathlib import Path 

58from typing import Dict, Optional as Nullable 

59 

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 

67 

68from pyEDAA.Reports.Unittesting import UnittestError, TestcaseStatus, TestsuiteKind 

69from pyEDAA.Reports.Unittesting import Document as ut_Document, TestsuiteSummary, Testsuite, Testcase 

70 

71 

72__all__ = ["XML_SCHEMA_INSTANCE_NAMESPACE", "SCHEMA_FILES", "STATUS_MAP"] 

73 

74XML_SCHEMA_INSTANCE_NAMESPACE = "http://www.w3.org/2001/XMLSchema-instance" #: Namespace of the ``xsi:*`` attributes. 

75 

76 

77@export 

78class FormatVersion(StringEnum): 

79 """ 

80 Version of pyTooling's test report format, as the XML schema a report names states it. 

81 """ 

82 

83 Version0_1 = "0.1" #: Format version 0.1, defined by :file:`TestReport-v0.1.xsd`. 

84 

85 

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. 

89 

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. 

98 

99 

100@export 

101class Document(TestsuiteSummary, ut_Document): 

102 """ 

103 A document reader for pyTooling's XML test report format. 

104 

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 """ 

112 

113 _xmlDocument: Nullable[_ElementTree] #: Parsed and validated XML document. 

114 _schemaVersion: Nullable[FormatVersion] #: Format version named by the report's schema location. 

115 

116 def __init__(self, xmlReportFile: Path, analyzeAndConvert: bool = False) -> None: 

117 """ 

118 Initializes the document reader. 

119 

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") 

124 

125 self._xmlDocument = None 

126 self._schemaVersion = None 

127 

128 ut_Document.__init__(self, xmlReportFile, analyzeAndConvert) 

129 

130 @readonly 

131 def SchemaVersion(self) -> FormatVersion: 

132 """ 

133 Read-only property to access the report format's version (:attr:`_schemaVersion`). 

134 

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. 

137 

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 

145 

146 return self._schemaVersion 

147 

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. 

151 

152 .. hint:: 

153 

154 The time spend for analysis will be made available via property :data:`AnalysisDuration`. 

155 

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.") 

168 

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 

174 

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 

179 

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 

185 

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.") 

189 

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 

197 

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 

202 

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 

207 

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 

213 

214 self._xmlDocument = xmlDocument 

215 self._schemaVersion = schemaVersion 

216 

217 self._analysisDuration = sw.Duration 

218 

219 def Convert(self) -> None: 

220 """ 

221 Convert the parsed and validated XML data structure into a test entity hierarchy. 

222 

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. 

225 

226 .. hint:: 

227 

228 The time spend for model conversion will be made available via property :data:`ModelConversionDuration`. 

229 

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 

238 

239 with Stopwatch() as sw: 

240 rootElement: _Element = self._xmlDocument.getroot() 

241 

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) 

247 

248 for element in rootElement.iterchildren(tag="Testsuite"): # type: _Element 

249 self._ConvertTestsuite(element, self) 

250 

251 self.Aggregate() 

252 

253 self._modelConversion = sw.Duration 

254 

255 def _ConvertTestsuite(self, testsuiteElement: _Element, parent: TestsuiteSummary | Testsuite) -> None: 

256 """ 

257 Convert a ``<Testsuite>`` element and its children to a test suite. 

258 

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) 

269 

270 for element in testsuiteElement.iterchildren(tag="Testsuite"): # type: _Element 

271 self._ConvertTestsuite(element, testsuite) 

272 

273 for element in testsuiteElement.iterchildren(tag="Testcase"): # type: _Element 

274 self._ConvertTestcase(element, testsuite) 

275 

276 def _ConvertTestcase(self, testcaseElement: _Element, parent: Testsuite) -> None: 

277 """ 

278 Convert a ``<Testcase>`` element and its children to a test case. 

279 

280 :param testcaseElement: The XML element node representing a test case. 

281 :param parent: The parent test suite. 

282 """ 

283 messageElement = testcaseElement.find("Message") 

284 

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) 

293 

294 if (nodeID := testcaseElement.attrib.get("nodeID", None)) is not None: 

295 testcase["nodeID"] = nodeID 

296 

297 def _ConvertDuration(self, element: _Element) -> Nullable[timedelta]: 

298 """ 

299 Convert the ``duration`` attribute (in seconds) of an XML element node. 

300 

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 

306 

307 return timedelta(seconds=float(duration)) 

308 

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. 

312 

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 

318 

319 if (summaryElement := element.find("Summary")) is not None: 

320 entity._summary = summaryElement.text 

321 

322 if (descriptionElement := element.find("Description")) is not None: 

323 entity._description = descriptionElement.text