Coverage for pyEDAA/Reports/Unittesting/OpenTestReporting/__init__.py: 94%

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

32A data model of Open Test Reporting's XML formats, which converts to the unified test entity hierarchy. 

33 

34`Open Test Reporting <https://github.com/ota4j-team/open-test-reporting>`__ (OTR) defines XML formats for test results, 

35independent of a test framework or programming language. The *event-based* format is written while the tests run, one 

36event per state change of a test or container. The *hierarchical* format nests tests in their containers. The JUnit 

37Platform writes the event-based format, OTR's command line tool converts it into the hierarchical format. 

38 

39Both formats describe a test or container by the same elements: metadata, sources, attachments and a result. This 

40package models them as a tree of :class:`Node` objects within an :class:`Execution`. A document reader per format, 

41derived from :class:`Document`, fills the tree and converts it into the unified test entity hierarchy of 

42:mod:`pyEDAA.Reports.Unittesting`. 

43 

44The records of the tree's elements - the infrastructure and a result - are in 

45:mod:`~pyEDAA.Reports.Unittesting.OpenTestReporting.Records`. 

46 

47.. rubric:: Example 

48 

49.. code-block:: Python 

50 

51 from pathlib import Path 

52 from pyEDAA.Reports.Unittesting.OpenTestReporting.Events import Document 

53 

54 document = Document(Path("build/test-results/test/open-test-report.xml"), analyzeAndConvert=True) 

55 summary = document.ToTestsuiteSummary() 

56 for testcase in summary.IterateTestcases(): 

57 print(f"{testcase.Name}: {testcase.Status.name}") 

58""" 

59from __future__ import annotations 

60 

61from datetime import datetime, timedelta 

62from enum import Enum 

63from io import BytesIO 

64from pathlib import Path 

65from typing import ClassVar, Dict, Generic, List, Optional as Nullable 

66from typing import Tuple, TypeVar 

67 

68from lxml.etree import QName, XMLParser, XMLSchema, XMLSchemaParseError 

69from lxml.etree import XMLSyntaxError, _Element, _ElementTree, parse 

70from pyTooling.Common import getFullyQualifiedName, getResourceFile, StringEnum 

71from pyTooling.Decorators import export, readonly 

72from pyTooling.Exceptions import ToolingException 

73from pyTooling.MetaClasses import ExtendedType, abstractmethod 

74from pyTooling.Stopwatch import Stopwatch 

75 

76from pyEDAA.Reports.Resources import OpenTestReporting as OpenTestReportingResources 

77from pyEDAA.Reports.Resources.OpenTestReporting import SPECIFICATIONS, Specification 

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

79from pyEDAA.Reports.Unittesting import Document as ut_Document 

80from pyEDAA.Reports.Unittesting import TestsuiteSummary as ut_TestsuiteSummary 

81from pyEDAA.Reports.Unittesting import Testsuite as ut_Testsuite, Testcase as ut_Testcase 

82from pyEDAA.Reports.Unittesting.OpenTestReporting.Records import Infrastructure, Result, ResultStatus 

83 

84 

85__all__ = ["STATUS_MAP"] 

86 

87 

88@export 

89class FormatVersion(StringEnum): 

90 """ 

91 Version of Open Test Reporting, as the URIs of its XML namespaces state it. 

92 

93 The XML namespaces and schemas of each version are in 

94 :data:`~pyEDAA.Reports.Resources.OpenTestReporting.SPECIFICATIONS`, keyed by the version. 

95 """ 

96 

97 Version0_2_0 = "0.2.0" #: Open Test Reporting 0.2.0. 

98 

99 

100ParentType = TypeVar("ParentType", bound="Node | Execution") 

101"""A type variable for the parent of a :class:`Node`: a :class:`Node`, or the :class:`Execution` of a root node.""" 

102 

103 

104@export 

105class NodeType(Enum): 

106 """An enumeration of the kinds of a node, as the JUnit Platform states them (``<junit:type>``).""" 

107 

108 Container = "CONTAINER" #: A container of tests or containers, e.g. a test class. 

109 Test = "TEST" #: A test, e.g. a test method or an invocation of a parameterized test. 

110 ContainerAndTest = "CONTAINER_AND_TEST" #: A test, which contains tests or containers too. 

111 

112 

113STATUS_MAP: Dict[ResultStatus, TestcaseStatus] = { 

114 ResultStatus.Successful: TestcaseStatus.Passed, 

115 ResultStatus.Skipped: TestcaseStatus.Skipped, 

116 ResultStatus.Aborted: TestcaseStatus.Skipped, 

117 ResultStatus.Failed: TestcaseStatus.Failed, 

118 ResultStatus.Errored: TestcaseStatus.Errored, 

119} #: Mapping of a result's status to a test case status. 

120 

121 

122@export 

123class Node(Generic[ParentType], metaclass=ExtendedType, slots=True): 

124 """ 

125 A test or a container of tests. 

126 

127 Test engines, test classes and parameterized tests are containers. 

128 

129 A node is named by its display name. Its metadata, sources, attachments and result are filled by a document reader. 

130 The JUnit Platform states additional metadata: a unique ID, a legacy reporting name and the node's type. 

131 """ 

132 

133 _parent: Nullable[ParentType] #: The parent node, or the execution of a root node. 

134 _children: List[Node[Node]] #: Child nodes in the order they were started. 

135 _name: str #: Display name. 

136 _startTime: datetime #: Time when the node was started. 

137 _duration: Nullable[timedelta] #: Duration from start to finish. 

138 _tags: List[str] #: Tags. 

139 _uniqueID: Nullable[str] #: JUnit Platform's unique ID. 

140 _legacyReportingName: Nullable[str] #: JUnit Platform's name for tools not showing display names. 

141 _type: Nullable[NodeType] #: JUnit Platform's kind of node. 

142 _className: Nullable[str] #: Name of the Java class (Java extension). 

143 _methodName: Nullable[str] #: Name of the Java method (Java extension). 

144 _methodParameterTypes: Nullable[str] #: Parameter types of the Java method (Java extension). 

145 _data: List[Tuple[str, str]] #: Key-value pairs of the data attachments in reported order. 

146 _output: Dict[str, str] #: Output attachments by source, e.g. ``stdout``. 

147 _files: List[Path] #: Paths of the file attachments. 

148 _result: Nullable[Result] #: Result, or ``None`` if the node didn't finish. 

149 

150 def __init__( 

151 self, 

152 name: str, 

153 startTime: datetime, 

154 duration: Nullable[timedelta] = None, 

155 *, 

156 parent: Nullable[ParentType] = None 

157 ) -> None: 

158 """ 

159 Initializes a node, and adds it to the children of its parent node or the root nodes of its execution. 

160 

161 :param name: Display name of the node. 

162 :param startTime: Time when the node was started. 

163 :param duration: Optional, duration from start to finish. 

164 :param parent: Optional, the parent node, or the execution of a root node. Default: ``None``. 

165 :raises TypeError: If parameter 'parent' is not of type :class:`Node` or :class:`Execution`. 

166 :raises ValueError: If parameter 'name' is None. 

167 :raises TypeError: If parameter 'name' is not of type :class:`str`. 

168 :raises ValueError: If parameter 'startTime' is None. 

169 :raises TypeError: If parameter 'startTime' is not of type :class:`~datetime.datetime`. 

170 :raises TypeError: If parameter 'duration' is not of type :class:`~datetime.timedelta`. 

171 """ 

172 if parent is not None and not isinstance(parent, (Node, Execution)): 

173 ex = TypeError("Parameter 'parent' is not of type 'Node' or 'Execution'.") 

174 ex.add_note(f"Got type '{getFullyQualifiedName(parent)}'.") 

175 raise ex 

176 

177 if name is None: 

178 raise ValueError("Parameter 'name' is None.") 

179 elif not isinstance(name, str): 179 ↛ 180line 179 didn't jump to line 180 because the condition on line 179 was never true

180 ex = TypeError("Parameter 'name' is not of type 'str'.") 

181 ex.add_note(f"Got type '{getFullyQualifiedName(name)}'.") 

182 raise ex 

183 

184 if startTime is None: 184 ↛ 185line 184 didn't jump to line 185 because the condition on line 184 was never true

185 raise ValueError("Parameter 'startTime' is None.") 

186 elif not isinstance(startTime, datetime): 

187 ex = TypeError("Parameter 'startTime' is not of type 'datetime'.") 

188 ex.add_note(f"Got type '{getFullyQualifiedName(startTime)}'.") 

189 raise ex 

190 

191 if duration is not None and not isinstance(duration, timedelta): 191 ↛ 192line 191 didn't jump to line 192 because the condition on line 191 was never true

192 ex = TypeError("Parameter 'duration' is not of type 'timedelta'.") 

193 ex.add_note(f"Got type '{getFullyQualifiedName(duration)}'.") 

194 raise ex 

195 

196 self._parent = parent 

197 self._children = [] 

198 self._name = name 

199 self._startTime = startTime 

200 self._duration = duration 

201 self._tags = [] 

202 self._uniqueID = None 

203 self._legacyReportingName = None 

204 self._type = None 

205 self._className = None 

206 self._methodName = None 

207 self._methodParameterTypes = None 

208 self._data = [] 

209 self._output = {} 

210 self._files = [] 

211 self._result = None 

212 

213 if isinstance(parent, Execution): 

214 parent._roots.append(self) 

215 elif parent is not None: 

216 parent._children.append(self) 

217 

218 @readonly 

219 def Parent(self) -> Nullable[ParentType]: 

220 """ 

221 Read-only property to access the parent node, or the execution of a root node (:attr:`_parent`). 

222 

223 :returns: The parent :class:`Node`, or the :class:`Execution` of a root node; ``None`` if the node belongs to 

224 neither. 

225 """ 

226 return self._parent 

227 

228 @readonly 

229 def Children(self) -> List[Node[Node]]: 

230 """ 

231 Read-only property to access the child nodes (:attr:`_children`). 

232 

233 :returns: The list of child nodes in the order they were started. 

234 """ 

235 return self._children 

236 

237 @readonly 

238 def Name(self) -> str: 

239 """ 

240 Read-only property to access the display name (:attr:`_name`). 

241 

242 The display name is given by the test, e.g. ``Tests of MyClass``, or generated, e.g. ``[1] "5", "5"`` for a 

243 parameterized test's invocation. 

244 

245 :returns: The display name. 

246 """ 

247 return self._name 

248 

249 @readonly 

250 def StartTime(self) -> datetime: 

251 """ 

252 Read-only property to access the time when the node was started (:attr:`_startTime`). 

253 

254 :returns: The start time. 

255 """ 

256 return self._startTime 

257 

258 @readonly 

259 def Duration(self) -> Nullable[timedelta]: 

260 """ 

261 Read-only property to access the duration from start to finish (:attr:`_duration`). 

262 

263 :returns: The duration, or ``None`` if the node didn't finish. 

264 """ 

265 return self._duration 

266 

267 @readonly 

268 def Tags(self) -> List[str]: 

269 """ 

270 Read-only property to access the tags (:attr:`_tags`). 

271 

272 :returns: The list of tags. 

273 """ 

274 return self._tags 

275 

276 @readonly 

277 def UniqueID(self) -> Nullable[str]: 

278 """ 

279 Read-only property to access the JUnit Platform's unique ID (:attr:`_uniqueID`). 

280 

281 A test class run by JUnit Jupiter has the unique ID ``[engine:junit-jupiter]/[class:my.pack.MyClassTest]``. 

282 

283 :returns: The unique ID, or ``None`` if it wasn't reported. 

284 """ 

285 return self._uniqueID 

286 

287 @readonly 

288 def LegacyReportingName(self) -> Nullable[str]: 

289 """ 

290 Read-only property to access the JUnit Platform's legacy reporting name (:attr:`_legacyReportingName`). 

291 

292 A test class has its qualified name, e.g. ``my.pack.MyClassTest``, a test method its name and parameters, e.g. 

293 ``testReturnTrue()``. 

294 

295 :returns: The legacy reporting name, or ``None`` if it wasn't reported. 

296 """ 

297 return self._legacyReportingName 

298 

299 @readonly 

300 def Type(self) -> Nullable[NodeType]: 

301 """ 

302 Read-only property to access the JUnit Platform's kind of node (:attr:`_type`). 

303 

304 :returns: The kind of node, or ``None`` if it wasn't reported. 

305 """ 

306 return self._type 

307 

308 @readonly 

309 def ClassName(self) -> Nullable[str]: 

310 """ 

311 Read-only property to access the name of the Java class (:attr:`_className`). 

312 

313 :returns: The fully qualified class name, or ``None`` if no Java class or method source was reported. 

314 """ 

315 return self._className 

316 

317 @readonly 

318 def MethodName(self) -> Nullable[str]: 

319 """ 

320 Read-only property to access the name of the Java method (:attr:`_methodName`). 

321 

322 :returns: The method name, or ``None`` if no Java method source was reported. 

323 """ 

324 return self._methodName 

325 

326 @readonly 

327 def MethodParameterTypes(self) -> Nullable[str]: 

328 """ 

329 Read-only property to access the parameter types of the Java method (:attr:`_methodParameterTypes`). 

330 

331 :returns: The comma-separated parameter types, or ``None`` if they weren't reported. 

332 """ 

333 return self._methodParameterTypes 

334 

335 @readonly 

336 def Data(self) -> List[Tuple[str, str]]: 

337 """ 

338 Read-only property to access the key-value pairs of the data attachments (:attr:`_data`). 

339 

340 :returns: The list of key-value pairs in the order they were reported. 

341 """ 

342 return self._data 

343 

344 @readonly 

345 def Output(self) -> Dict[str, str]: 

346 """ 

347 Read-only property to access the output attachments (:attr:`_output`). 

348 

349 An output's source is e.g. ``stdout`` or ``stderr``. Output of the same source is concatenated in the order it was 

350 reported. 

351 

352 :returns: The dictionary of output by source. 

353 """ 

354 return self._output 

355 

356 @readonly 

357 def Files(self) -> List[Path]: 

358 """ 

359 Read-only property to access the paths of the file attachments (:attr:`_files`). 

360 

361 :returns: The list of paths. 

362 """ 

363 return self._files 

364 

365 @readonly 

366 def Result(self) -> Nullable[Result]: 

367 """ 

368 Read-only property to access the result (:attr:`_result`). 

369 

370 :returns: The result, or ``None`` if the node didn't finish. 

371 """ 

372 return self._result 

373 

374 @readonly 

375 def IsTest(self) -> bool: 

376 """ 

377 Read-only property to return whether the node is a test. 

378 

379 A node is a test, if the JUnit Platform states it as ``TEST``. A node of another or no stated type is a test, if it 

380 has no child nodes. 

381 

382 :returns: True, if the node is a test. 

383 """ 

384 if self._type is NodeType.Test: 

385 return True 

386 elif self._type is NodeType.Container: 

387 return False 

388 

389 return len(self._children) == 0 

390 

391 def ToTestsuite(self, hostname: Nullable[str] = None) -> ut_Testsuite: 

392 """ 

393 Convert this container to a test suite of the unified data model. 

394 

395 The test suite is named by the legacy reporting name, if the JUnit Platform reported one, else by the display name. 

396 Then, the display name becomes the test suite's title. A container of a Java class becomes a test suite of kind 

397 ``Class``. Child containers become test suites, child tests become test cases. 

398 

399 :param hostname: Optional, name of the host the test suite was executed on. 

400 :returns: A test suite of the unified test entity data model. 

401 :raises DuplicateTestsuiteError: If two child containers have the same name. 

402 :raises DuplicateTestcaseError: If two child tests have the same name. 

403 """ 

404 return ut_Testsuite( 

405 self._name if self._legacyReportingName is None else self._legacyReportingName, 

406 title=None if self._legacyReportingName is None else self._name, 

407 kind=TestsuiteKind.Class if self._className is not None and self._methodName is None else TestsuiteKind.Logical, 

408 hostname=hostname, 

409 startTime=self._startTime, 

410 totalDuration=self._duration, 

411 testsuites=[child.ToTestsuite() for child in self._children if not child.IsTest], 

412 testcases=[child.ToTestcase() for child in self._children if child.IsTest] 

413 ) 

414 

415 def ToTestcase(self) -> ut_Testcase: 

416 """ 

417 Convert this test to a test case of the unified data model. 

418 

419 The test case is named by the legacy reporting name, if the JUnit Platform reported one, else by the display name. 

420 Then, the display name becomes the test case's title. The status is mapped by :data:`STATUS_MAP`; a test without a 

421 result status is ``Inconsistent``. The result's message and stack trace become the test case's message and details, 

422 the output on ``stdout`` and ``stderr`` its captured output streams, and the data attachments its key-value pairs. 

423 

424 :returns: A test case of the unified test entity data model. 

425 """ 

426 if self._result is None: 

427 status = TestcaseStatus.Inconsistent 

428 message = None 

429 details = None 

430 else: 

431 status = TestcaseStatus.Inconsistent if self._result._status is None else STATUS_MAP[self._result._status] 

432 message = self._result.Message 

433 details = self._result._stackTrace 

434 

435 testcase = ut_Testcase( 

436 self._name if self._legacyReportingName is None else self._legacyReportingName, 

437 title=None if self._legacyReportingName is None else self._name, 

438 startTime=self._startTime, 

439 totalDuration=self._duration, 

440 status=status, 

441 message=message, 

442 details=details, 

443 standardOutput=self._output.get("stdout", None), 

444 standardError=self._output.get("stderr", None) 

445 ) 

446 

447 for key, value in self._data: 

448 testcase[key] = value 

449 

450 return testcase 

451 

452 

453@export 

454class Execution(metaclass=ExtendedType, mixin=True): 

455 """ 

456 A mixin-class representing a test run: the infrastructure the tests were run on and the tree of tests and containers. 

457 

458 A document reader fills the infrastructure and adds a root node per node without parent, e.g. per test engine. Both 

459 are added by creating them with the execution as their parent. 

460 """ 

461 

462 _infrastructure: Nullable[Infrastructure] #: Infrastructure the tests were run on. 

463 _roots: List[Node[Execution]] #: Root nodes in the order they were started. 

464 

465 def __init__(self) -> None: 

466 """Initializes an execution without infrastructure and nodes.""" 

467 self._infrastructure = None 

468 self._roots = [] 

469 

470 @readonly 

471 def Infrastructure(self) -> Nullable[Infrastructure]: 

472 """ 

473 Read-only property to access the infrastructure the tests were run on (:attr:`_infrastructure`). 

474 

475 :returns: The infrastructure, or ``None`` if it wasn't reported. 

476 """ 

477 return self._infrastructure 

478 

479 @readonly 

480 def Roots(self) -> List[Node[Execution]]: 

481 """ 

482 Read-only property to access the root nodes (:attr:`_roots`). 

483 

484 A JUnit Platform report's root nodes are its test engines. 

485 

486 :returns: The list of root nodes in the order they were started. 

487 """ 

488 return self._roots 

489 

490 

491# 'Document' has properties named 'FormatVersion' and 'Specification', which a field's or a property's annotation in the 

492# class body would name instead of the classes. 

493_FormatVersion = FormatVersion 

494_Specification = Specification 

495 

496 

497@export 

498class Document(ut_Document, Execution, slots=True): 

499 """ 

500 Base-class of the document readers for Open Test Reporting's XML formats. 

501 

502 The format version is read from the root element's namespace. The file is validated against the XML schema of 

503 that version's :class:`~pyEDAA.Reports.Resources.OpenTestReporting.Specification`, which imports the schemas of all 

504 namespaces: the core namespace, both formats, and the Java, Git and JUnit Platform extensions. Both formats describe a 

505 test or container by the same elements of the core namespace, which this class converts. A derived class converts 

506 the format's structure into the tree of :class:`Node` objects. 

507 """ 

508 

509 _ROOT_NAME: ClassVar[str] #: Prefixed name of the format's root element, e.g. ``e:events``. 

510 

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

512 _formatVersion: Nullable[_FormatVersion] #: Version of Open Test Reporting the file states, after :meth:`Analyze`. 

513 

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

515 """ 

516 Initializes the document reader. 

517 

518 :param xmlReportFile: Path to the XML report file. 

519 :param analyzeAndConvert: Optional, if true, analyze (parse and validate) the file and convert its content. 

520 """ 

521 super().__init__(xmlReportFile) 

522 Execution.__init__(self) 

523 

524 self._xmlDocument = None 

525 self._formatVersion = None 

526 

527 if analyzeAndConvert: 

528 self.Analyze() 

529 self.Convert() 

530 

531 @readonly 

532 def FormatVersion(self) -> Nullable[_FormatVersion]: 

533 """ 

534 Read-only property to access the version of Open Test Reporting the file states (:attr:`_formatVersion`). 

535 

536 :returns: The format version, or ``None`` before :meth:`Analyze`. 

537 """ 

538 return self._formatVersion 

539 

540 @readonly 

541 def Specification(self) -> _Specification: 

542 """ 

543 Read-only property to return the XML namespaces and schemas of the file's format version. 

544 

545 :returns: The specification of :attr:`FormatVersion`. 

546 :raises UnittestError: If the file wasn't analyzed yet. 

547 """ 

548 if self._formatVersion is None: 

549 raise UnittestError(f"Format version of Open Test Reporting file '{self._path}' is known after 'Analyze()'.") 

550 

551 return SPECIFICATIONS[self._formatVersion] 

552 

553 @abstractmethod 

554 def _RootTag(self, specification: _Specification) -> str: 

555 """ 

556 Return the qualified tag of the format's root element in a version of Open Test Reporting. 

557 

558 :param specification: The XML namespaces and schemas of a version. 

559 :returns: The tag of the root element, qualified by the format's namespace URI. 

560 """ 

561 

562 def Analyze(self) -> None: 

563 """ 

564 Analyze the XML file: parse it, check its root element and validate it against the XML schema. 

565 

566 .. hint:: 

567 

568 The time spent for analysis will be made available via property :data:`AnalysisDuration`. 

569 

570 :raises UnittestError: If the file doesn't exist. 

571 :raises UnittestError: If the file can't be read. 

572 :raises UnittestError: If the file isn't well-formed XML. 

573 :raises UnittestError: If the root element isn't the format's root element of a supported version of Open Test 

574 Reporting. |br| 

575 The exception notes the supported versions. 

576 :raises UnittestError: If the XML schema can't be located or parsed. 

577 :raises UnittestError: If the file isn't valid according to the XML schema. 

578 """ 

579 if not self._path.exists(): 

580 raise UnittestError(f"Open Test Reporting file '{self._path}' does not exist.") \ 

581 from FileNotFoundError(f"File '{self._path}' not found.") 

582 

583 with Stopwatch() as sw: 

584 try: 

585 content = self._path.read_bytes() 

586 except OSError as ex: 

587 raise UnittestError(f"Couldn't read Open Test Reporting file '{self._path}'.") from ex 

588 

589 try: 

590 xmlDocument = parse(BytesIO(content), XMLParser(ns_clean=True), base_url=str(self._path)) 

591 except XMLSyntaxError as ex: 

592 raise UnittestError(f"XML syntax error in Open Test Reporting file '{self._path}'.") from ex 

593 

594 rootElement: _Element = xmlDocument.getroot() 

595 for formatVersion in FormatVersion: 

596 if rootElement.tag == self._RootTag(SPECIFICATIONS[formatVersion]): 

597 break 

598 else: 

599 ex = UnittestError(f"Root element of '{self._path}' is not '<{self._ROOT_NAME}>' of Open Test Reporting.") 

600 ex.add_note(f"Got root element '{rootElement.tag}'.") 

601 ex.add_note(f"Supported versions: {', '.join(FormatVersion)}.") 

602 raise ex 

603 

604 schemaFile = SPECIFICATIONS[formatVersion].SchemaFile 

605 try: 

606 schemaResourceFile = getResourceFile(OpenTestReportingResources, schemaFile) 

607 except ToolingException as ex: 

608 raise UnittestError(f"Couldn't locate XML Schema '{schemaFile}' in package resources.") from ex 

609 

610 try: 

611 xmlSchema = XMLSchema(parse(schemaResourceFile, XMLParser(ns_clean=True))) 

612 except (XMLSyntaxError, XMLSchemaParseError) as ex: 

613 raise UnittestError(f"Error while parsing XML Schema '{schemaFile}'.") from ex 

614 

615 if not xmlSchema.validate(xmlDocument): 

616 ex = UnittestError(f"Validation error for '{self._path}' using XSD schema '{schemaFile}'.") 

617 for logEntry in xmlSchema.error_log: 

618 ex.add_note(str(logEntry)) 

619 raise ex 

620 

621 self._xmlDocument = xmlDocument 

622 self._formatVersion = formatVersion 

623 

624 self._analysisDuration = sw.Duration 

625 

626 @abstractmethod 

627 def Convert(self) -> None: 

628 """Convert the parsed and validated XML data structure into a tree of nodes.""" 

629 

630 def _ConvertInfrastructure(self, infrastructureElement: _Element) -> None: 

631 """ 

632 Convert the ``<infrastructure>`` element. 

633 

634 :param infrastructureElement: The XML element node describing the infrastructure. 

635 """ 

636 core = self.Specification.Core.URI 

637 

638 infrastructure = Infrastructure(parent=self) 

639 for element in infrastructureElement.iterchildren("*"): # type: _Element 

640 if element.tag == f"{{{core}}}hostName": 

641 infrastructure._hostName = element.text 

642 elif element.tag == f"{{{core}}}userName": 

643 infrastructure._userName = element.text 

644 elif element.tag == f"{{{core}}}operatingSystem": 

645 infrastructure._operatingSystem = element.text 

646 elif element.tag == f"{{{core}}}cpuCores": 

647 infrastructure._cpuCoreCount = int(element.text) 

648 else: 

649 localName = QName(element).localname 

650 if element.text is not None: 

651 infrastructure._extensions[localName] = element.text 

652 

653 for attributeName, value in element.attrib.items(): 

654 infrastructure._extensions[f"{localName}.{attributeName}"] = value 

655 

656 def _ConvertTestInfo(self, element: _Element, node: Node) -> None: 

657 """ 

658 Convert the metadata, sources, attachments and result of an event. 

659 

660 :param element: The XML element node of an event. 

661 :param node: The node to update. 

662 """ 

663 core = self.Specification.Core.URI 

664 java = self.Specification.Java.URI 

665 junit = self.Specification.JUnit.URI 

666 

667 for child in element.iterchildren("*"): # type: _Element 

668 if child.tag == f"{{{core}}}metadata": 

669 for metadataElement in child.iterchildren("*"): # type: _Element 

670 if metadataElement.tag == f"{{{core}}}tags": 

671 for tagElement in metadataElement.iterchildren(f"{{{core}}}tag"): # type: _Element 

672 node._tags.append("" if tagElement.text is None else tagElement.text) 

673 elif metadataElement.tag == f"{{{junit}}}uniqueId": 

674 node._uniqueID = metadataElement.text 

675 elif metadataElement.tag == f"{{{junit}}}legacyReportingName": 

676 node._legacyReportingName = metadataElement.text 

677 elif metadataElement.tag == f"{{{junit}}}type": 677 ↛ 669line 677 didn't jump to line 669 because the condition on line 677 was always true

678 node._type = NodeType(metadataElement.text) 

679 elif child.tag == f"{{{core}}}sources": 

680 sourceTags = (f"{{{java}}}classSource", f"{{{java}}}methodSource") 

681 for sourceElement in child.iterchildren(*sourceTags): # type: _Element 

682 node._className = sourceElement.attrib["className"] 

683 node._methodName = sourceElement.attrib.get("methodName", None) 

684 node._methodParameterTypes = sourceElement.attrib.get("methodParameterTypes", None) 

685 elif child.tag == f"{{{core}}}attachments": 

686 for attachmentElement in child.iterchildren("*"): # type: _Element 

687 if attachmentElement.tag == f"{{{core}}}data": 

688 for entryElement in attachmentElement.iterchildren(f"{{{core}}}entry"): # type: _Element 

689 value = "" if entryElement.text is None else entryElement.text 

690 node._data.append((entryElement.attrib["key"], value)) 

691 elif attachmentElement.tag == f"{{{core}}}output": 691 ↛ 695line 691 didn't jump to line 695 because the condition on line 691 was always true

692 source = attachmentElement.attrib["source"] 

693 text = "" if attachmentElement.text is None else attachmentElement.text 

694 node._output[source] = node._output.get(source, "") + text 

695 elif attachmentElement.tag == f"{{{core}}}file": 

696 node._files.append(Path(attachmentElement.attrib["path"])) 

697 elif child.tag == f"{{{core}}}result": 

698 status = child.attrib.get("status", None) 

699 reasonElement = child.find(f"{{{core}}}reason") 

700 throwableElement = child.find(f"{{{java}}}throwable") 

701 

702 Result( 

703 None if status is None else ResultStatus(status), 

704 None if reasonElement is None else reasonElement.text, 

705 None if throwableElement is None else throwableElement.attrib["type"], 

706 None if throwableElement is None else throwableElement.attrib["assertionError"] in ("true", "1"), 

707 None if throwableElement is None else throwableElement.text, 

708 parent=node 

709 ) 

710 

711 def ToTestsuiteSummary(self) -> ut_TestsuiteSummary: 

712 """ 

713 Convert the tree of nodes to a test suite summary of the unified data model. 

714 

715 The test suite summary is named after the report file (without extension). Every root node becomes a test suite, 

716 executed on the reported host. The summary starts with the first root node and lasts until the last one finished. 

717 Afterwards, the hierarchy is aggregated, so all counters and states are computed. 

718 

719 :returns: A test suite summary of the unified test entity data model. 

720 :raises UnittestError: If a root node is a test. 

721 """ 

722 hostname = None if self._infrastructure is None else self._infrastructure._hostName 

723 

724 testsuites: List[ut_Testsuite] = [] 

725 for root in self._roots: 

726 if root.IsTest: 

727 ex = UnittestError(f"Test '{root._name}' in '{self._path}' has no container.") 

728 ex.add_note("A test case of the unified data model needs a test suite as parent.") 

729 raise ex 

730 

731 testsuites.append(root.ToTestsuite(hostname)) 

732 

733 startTime = min((root._startTime for root in self._roots), default=None) 

734 finishTime = max( 

735 (root._startTime + root._duration for root in self._roots if root._duration is not None), 

736 default=None 

737 ) 

738 

739 summary = ut_TestsuiteSummary( 

740 self._path.stem, 

741 startTime=startTime, 

742 totalDuration=None if finishTime is None else finishTime - startTime, 

743 testsuites=testsuites 

744 ) 

745 summary.Aggregate() 

746 

747 return summary