Coverage for pyEDAA/Reports/CodeCoverage/JaCoCo.py: 91%

599 statements  

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

32JaCoCo's XML code coverage format: a model of the format, read from a report and converted to the common model. 

33 

34JaCoCo - the Java code coverage library - writes its XML report with ``jacococli.jar report --xml``, Maven's goals 

35``report`` and ``report-aggregate``, Ant's task ``report`` or Gradle's task ``jacocoTestReport``. A report names its 

36DTD by a public identifier, e.g. ``-//JACOCO//DTD Report 1.1//EN``, whose version (:class:`FormatVersion`) chooses the 

37XML schema a report is validated against: :file:`JaCoCo-1.1.xsd`, a translation of JaCoCo's DTD ``report.dtd``. The 

38format's model keeps what the report states: a :class:`Document` holds :class:`SessionInfo` elements, :class:`Group` 

39elements - nested -, and :class:`Package` elements; a package holds :class:`Class` elements with their :class:`Method` 

40elements, and :class:`SourceFile` elements with their :class:`Line` elements. Every element but a line and a session 

41states its :class:`Counter` elements. Each element's constructor takes typed values, so the model can be built by hand: 

42an element below the report names its parent with the keyword parameter ``parent`` and is added to it. Its class method 

43``Parse`` reads the element's XML element. 

44 

45:meth:`Document.ToCoverageSummary` converts the model to the common model of :mod:`pyEDAA.Reports.CodeCoverage`: 

46 

47* A source file's path is its package's name in VM notation - e.g. ``my/pack`` - and its name, relative to a source 

48 directory, which the report doesn't name. 

49* A line ran, if it has a covered instruction - partially covered, if it has a missed branch. Its missed and covered 

50 branches become as many branches. The format has no counts, no branch targets and no excluded lines. 

51* The packages, classes and methods become units of the logical hierarchy; a package's name is split at ``/`` into 

52 nested packages. A method spans its first line and the next lines of its file, as many as its ``LINE`` counter 

53 counts; a class spans its methods. The groups have no counterpart. 

54 

55.. rubric:: Example 

56 

57.. code-block:: Python 

58 

59 from pathlib import Path 

60 from pyEDAA.Reports.CodeCoverage.JaCoCo import Document 

61 

62 report = Document(Path("jacocoTestReport.xml"), analyzeAndConvert=True) 

63 summary = report.ToCoverageSummary() 

64 for unit in summary.IterateUnits(): 

65 print(f"{unit.QualifiedName}: {unit.LineCoverage:.1%}") 

66""" 

67from __future__ import annotations 

68 

69from datetime import datetime, timedelta, timezone 

70from io import BytesIO 

71from itertools import islice 

72from pathlib import Path 

73from re import compile as re_compile 

74from typing import Generator, Generic, Optional as Nullable, Self, TypeVar 

75 

76from lxml.etree import XMLParser, XMLSchema, XMLSchemaParseError, XMLSyntaxError, _Element, \ 

77 _ElementTree, parse 

78from pyTooling.Common import getFullyQualifiedName, getResourceFile, StringEnum 

79from pyTooling.Decorators import export, readonly 

80from pyTooling.Exceptions import ToolingException 

81from pyTooling.MetaClasses import ExtendedType, abstractclass 

82from pyTooling.Stopwatch import Stopwatch 

83 

84from pyEDAA.Reports import Resources 

85from pyEDAA.Reports.CodeCoverage import Branch as cc_Branch, Class as cc_Class, CodeCoverageError, CoverageSummary, \ 

86 Document as cc_Document, Line as cc_Line, LineCoverageStatus, \ 

87 Method as cc_Method, Package as cc_Package, Unit as cc_Unit 

88 

89 

90__all__ = ["PUBLIC_IDENTIFIER", "SCHEMAS"] 

91 

92#: Pattern of the DTD's public identifier a report states, capturing the version, e.g. ``1.1``. 

93PUBLIC_IDENTIFIER = re_compile(r"-//JACOCO//DTD Report (\S+)//EN") 

94 

95ParentType = TypeVar("ParentType", bound="Report | Group") 

96"""A type variable for the parent of a :class:`Group` or :class:`Package`: a :class:`Report` or a :class:`Group`.""" 

97 

98 

99@export 

100class FormatVersion(StringEnum): 

101 """ 

102 Version of the JaCoCo XML format: the version of the DTD, whose public identifier a report states. 

103 """ 

104 

105 Version1_1 = "1.1" #: Format 1.1, public identifier ``-//JACOCO//DTD Report 1.1//EN``. 

106 

107 

108# A class with a property named like a class - ``FormatVersion`` - can't name that class in the annotation of a field: 

109# the class body's namespace, where annotations are evaluated, binds the name to the property. 

110_FormatVersion = FormatVersion 

111 

112SCHEMAS: dict[FormatVersion, str] = { 

113 FormatVersion.Version1_1: "JaCoCo-1.1.xsd" 

114} #: Per format version, the XML schema a report of that version is validated against. 

115 

116 

117@export 

118class CounterType(StringEnum): 

119 """ 

120 Kind of items a ``<counter>`` counts, as its ``type`` states. 

121 """ 

122 

123 Instruction = "INSTRUCTION" #: Java bytecode instructions. 

124 Branch = "BRANCH" #: Branches of ``if`` and ``switch`` statements. 

125 Line = "LINE" #: Source lines with at least one instruction. 

126 Complexity = "COMPLEXITY" #: Cyclomatic complexity: paths, which in linear combination generate all paths. 

127 Method = "METHOD" #: Methods - also constructors and static initializers - with at least one instruction. 

128 Class = "CLASS" #: Classes with at least one method. 

129 

130 

131@export 

132class CountersMixin(metaclass=ExtendedType, mixin=True): 

133 """ 

134 A mixin-class adding the counters an element states: per kind of items, how many were missed and covered. 

135 """ 

136 

137 _counters: dict[CounterType, Counter] #: The counters, by kind. 

138 

139 def __init__(self) -> None: 

140 """ 

141 Initialize the counters to none. 

142 

143 A counter is added by creating it with the element as its parent. 

144 """ 

145 self._counters = {} 

146 

147 @readonly 

148 def Counters(self) -> dict[CounterType, Counter]: 

149 """ 

150 Read-only property to access the counters (:attr:`_counters`). 

151 

152 :returns: The counters, by kind; a kind without items has no counter. 

153 """ 

154 return self._counters 

155 

156 

157@export 

158@abstractclass 

159class Base(metaclass=ExtendedType, slots=True): 

160 """ 

161 Base-class of the named elements below the report. 

162 

163 Packages and classes are named elements. 

164 """ 

165 

166 _name: str #: Name of the element. 

167 

168 def __init__(self, name: str) -> None: 

169 """ 

170 Initialize the name of the element. 

171 

172 :param name: Name of the element. 

173 :raises ValueError: If parameter ``name`` is ``None``. 

174 :raises TypeError: If parameter ``name`` isn't of type :class:`str`. 

175 """ 

176 if name is None: 

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

178 elif not isinstance(name, str): 

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

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

181 raise ex 

182 

183 self._name = name 

184 

185 @readonly 

186 def Name(self) -> str: 

187 """ 

188 Read-only property to access the name of the element (:attr:`_name`). 

189 

190 :returns: The name. 

191 """ 

192 return self._name 

193 

194 

195@export 

196class Counter(metaclass=ExtendedType, slots=True): 

197 """ 

198 A ``<counter>`` of an element: how many items of a kind were missed and covered. 

199 """ 

200 

201 _parent: Nullable[CountersMixin] #: The element the counter belongs to. 

202 _type: CounterType #: Kind of items the counter counts. 

203 _missedCount: int #: Number of missed items. 

204 _coveredCount: int #: Number of covered items. 

205 

206 def __init__( 

207 self, 

208 counterType: CounterType, 

209 missedCount: int, 

210 coveredCount: int, 

211 *, 

212 parent: Nullable[CountersMixin] = None 

213 ) -> None: 

214 """ 

215 Initialize the counter, and add it to the counters of its element. 

216 

217 :param counterType: Kind of items the counter counts. 

218 :param missedCount: Number of missed items. 

219 :param coveredCount: Number of covered items. 

220 :param parent: Optional, the element the counter belongs to; the counter is added to its counters by 

221 :attr:`Type`. Default: ``None``. 

222 :raises ValueError: If parameter ``counterType`` is ``None``. 

223 :raises TypeError: If parameter ``counterType`` isn't of type :class:`CounterType`. 

224 :raises ValueError: If parameter ``missedCount`` is ``None``. 

225 :raises TypeError: If parameter ``missedCount`` isn't of type :class:`int`. 

226 :raises ValueError: If parameter ``missedCount`` is negative. 

227 :raises ValueError: If parameter ``coveredCount`` is ``None``. 

228 :raises TypeError: If parameter ``coveredCount`` isn't of type :class:`int`. 

229 :raises ValueError: If parameter ``coveredCount`` is negative. 

230 :raises TypeError: If parameter ``parent`` isn't of type :class:`CountersMixin`. 

231 """ 

232 if counterType is None: 232 ↛ 233line 232 didn't jump to line 233 because the condition on line 232 was never true

233 raise ValueError("Parameter 'counterType' is None.") 

234 elif not isinstance(counterType, CounterType): 

235 ex = TypeError("Parameter 'counterType' is not of type 'CounterType'.") 

236 ex.add_note(f"Got type '{getFullyQualifiedName(counterType)}'.") 

237 raise ex 

238 

239 if missedCount is None: 

240 raise ValueError("Parameter 'missedCount' is None.") 

241 elif not isinstance(missedCount, int): 241 ↛ 242line 241 didn't jump to line 242 because the condition on line 241 was never true

242 ex = TypeError("Parameter 'missedCount' is not of type 'int'.") 

243 ex.add_note(f"Got type '{getFullyQualifiedName(missedCount)}'.") 

244 raise ex 

245 elif missedCount < 0: 245 ↛ 246line 245 didn't jump to line 246 because the condition on line 245 was never true

246 ex = ValueError("Parameter 'missedCount' is negative.") 

247 ex.add_note(f"Got value '{missedCount}'.") 

248 raise ex 

249 

250 if coveredCount is None: 250 ↛ 251line 250 didn't jump to line 251 because the condition on line 250 was never true

251 raise ValueError("Parameter 'coveredCount' is None.") 

252 elif not isinstance(coveredCount, int): 252 ↛ 253line 252 didn't jump to line 253 because the condition on line 252 was never true

253 ex = TypeError("Parameter 'coveredCount' is not of type 'int'.") 

254 ex.add_note(f"Got type '{getFullyQualifiedName(coveredCount)}'.") 

255 raise ex 

256 elif coveredCount < 0: 

257 ex = ValueError("Parameter 'coveredCount' is negative.") 

258 ex.add_note(f"Got value '{coveredCount}'.") 

259 raise ex 

260 

261 if parent is not None and not isinstance(parent, CountersMixin): 

262 ex = TypeError("Parameter 'parent' is not of type 'CountersMixin'.") 

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

264 raise ex 

265 

266 self._parent = parent 

267 self._type = counterType 

268 self._missedCount = missedCount 

269 self._coveredCount = coveredCount 

270 

271 if parent is not None: 271 ↛ exitline 271 didn't return from function '__init__' because the condition on line 271 was always true

272 parent._counters[self._type] = self 

273 

274 @classmethod 

275 def Parse(cls, element: _Element, *, parent: Nullable[CountersMixin] = None) -> Self: 

276 """ 

277 Parse a counter from its ``<counter>`` element. 

278 

279 :param element: The ``<counter>`` element. 

280 :param parent: Optional, the element the counter belongs to. Default: ``None``. 

281 :returns: The counter. 

282 """ 

283 return cls( 

284 CounterType(element.attrib["type"]), 

285 int(element.attrib["missed"]), 

286 int(element.attrib["covered"]), 

287 parent=parent 

288 ) 

289 

290 @readonly 

291 def Parent(self) -> Nullable[CountersMixin]: 

292 """ 

293 Read-only property to access the element the counter belongs to (:attr:`_parent`). 

294 

295 :returns: The element; ``None`` if the counter belongs to no element. 

296 """ 

297 return self._parent 

298 

299 @readonly 

300 def Type(self) -> CounterType: 

301 """ 

302 Read-only property to access the kind of items the counter counts (:attr:`_type`). 

303 

304 :returns: The kind of items. 

305 """ 

306 return self._type 

307 

308 @readonly 

309 def MissedCount(self) -> int: 

310 """ 

311 Read-only property to access the number of missed items (:attr:`_missedCount`). 

312 

313 :returns: The number of missed items. 

314 """ 

315 return self._missedCount 

316 

317 @readonly 

318 def CoveredCount(self) -> int: 

319 """ 

320 Read-only property to access the number of covered items (:attr:`_coveredCount`). 

321 

322 :returns: The number of covered items. 

323 """ 

324 return self._coveredCount 

325 

326 

327@export 

328class SessionInfo(metaclass=ExtendedType, slots=True): 

329 """ 

330 A ``<sessioninfo>`` of the report: a session, which contributed execution data - its ID, its start and its dump. 

331 """ 

332 

333 _parent: Nullable[Report] #: The report the session belongs to. 

334 _sessionID: str #: ID of the session. 

335 _start: datetime #: Time the session started. 

336 _dump: datetime #: Time the session's execution data was dumped. 

337 

338 def __init__(self, sessionID: str, start: datetime, dump: datetime, *, parent: Nullable[Report] = None) -> None: 

339 """ 

340 Initialize the session, and append it to the sessions of its report. 

341 

342 :param sessionID: ID of the session, e.g. the host's name and a random number. 

343 :param start: Time the session started. 

344 :param dump: Time the session's execution data was dumped. 

345 :param parent: Optional, the report the session belongs to; the session is appended to its sessions. 

346 Default: ``None``. 

347 :raises ValueError: If parameter ``sessionID`` is ``None``. 

348 :raises TypeError: If parameter ``sessionID`` isn't of type :class:`str`. 

349 :raises ValueError: If parameter ``start`` is ``None``. 

350 :raises TypeError: If parameter ``start`` isn't of type :class:`~datetime.datetime`. 

351 :raises ValueError: If parameter ``dump`` is ``None``. 

352 :raises TypeError: If parameter ``dump`` isn't of type :class:`~datetime.datetime`. 

353 :raises TypeError: If parameter ``parent`` isn't of type :class:`Report`. 

354 """ 

355 if sessionID is None: 355 ↛ 356line 355 didn't jump to line 356 because the condition on line 355 was never true

356 raise ValueError("Parameter 'sessionID' is None.") 

357 elif not isinstance(sessionID, str): 357 ↛ 358line 357 didn't jump to line 358 because the condition on line 357 was never true

358 ex = TypeError("Parameter 'sessionID' is not of type 'str'.") 

359 ex.add_note(f"Got type '{getFullyQualifiedName(sessionID)}'.") 

360 raise ex 

361 

362 if start is None: 362 ↛ 363line 362 didn't jump to line 363 because the condition on line 362 was never true

363 raise ValueError("Parameter 'start' is None.") 

364 elif not isinstance(start, datetime): 

365 ex = TypeError("Parameter 'start' is not of type 'datetime'.") 

366 ex.add_note(f"Got type '{getFullyQualifiedName(start)}'.") 

367 raise ex 

368 

369 if dump is None: 369 ↛ 370line 369 didn't jump to line 370 because the condition on line 369 was never true

370 raise ValueError("Parameter 'dump' is None.") 

371 elif not isinstance(dump, datetime): 371 ↛ 372line 371 didn't jump to line 372 because the condition on line 371 was never true

372 ex = TypeError("Parameter 'dump' is not of type 'datetime'.") 

373 ex.add_note(f"Got type '{getFullyQualifiedName(dump)}'.") 

374 raise ex 

375 

376 if parent is not None and not isinstance(parent, Report): 

377 ex = TypeError("Parameter 'parent' is not of type 'Report'.") 

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

379 raise ex 

380 

381 self._parent = parent 

382 self._sessionID = sessionID 

383 self._start = start 

384 self._dump = dump 

385 

386 if parent is not None: 

387 parent._sessionInfos.append(self) 

388 

389 @classmethod 

390 def Parse(cls, element: _Element, *, parent: Nullable[Report] = None) -> Self: 

391 """ 

392 Parse a session from its ``<sessioninfo>`` element. 

393 

394 The times are stated in milliseconds since the epoch. 

395 

396 :param element: The ``<sessioninfo>`` element. 

397 :param parent: Optional, the report the session belongs to. Default: ``None``. 

398 :returns: The session. 

399 """ 

400 epoch = datetime(1970, 1, 1, tzinfo=timezone.utc) 

401 return cls( 

402 element.attrib["id"], 

403 epoch + timedelta(milliseconds=int(element.attrib["start"])), 

404 epoch + timedelta(milliseconds=int(element.attrib["dump"])), 

405 parent=parent 

406 ) 

407 

408 @readonly 

409 def Parent(self) -> Nullable[Report]: 

410 """ 

411 Read-only property to access the report the session belongs to (:attr:`_parent`). 

412 

413 :returns: The report; ``None`` if the session belongs to no report. 

414 """ 

415 return self._parent 

416 

417 @readonly 

418 def SessionID(self) -> str: 

419 """ 

420 Read-only property to access the ID of the session (:attr:`_sessionID`). 

421 

422 JaCoCo's agent names a session by the host's name and a random number, e.g. ``dffa20ef8728-f9da5560``. 

423 

424 :returns: The ID. 

425 """ 

426 return self._sessionID 

427 

428 @readonly 

429 def Start(self) -> datetime: 

430 """ 

431 Read-only property to access the time the session started (:attr:`_start`). 

432 

433 :returns: The time, with time zone UTC, if read from a report. 

434 """ 

435 return self._start 

436 

437 @readonly 

438 def Dump(self) -> datetime: 

439 """ 

440 Read-only property to access the time the session's execution data was dumped (:attr:`_dump`). 

441 

442 :returns: The time, with time zone UTC, if read from a report. 

443 """ 

444 return self._dump 

445 

446 

447@export 

448class Method(Base, CountersMixin): 

449 """ 

450 A ``<method>`` of a class: its descriptor, its first line and its counters. 

451 """ 

452 

453 _parent: Nullable[Class] #: The class the method belongs to. 

454 _descriptor: str #: Descriptor of the method: the types of its parameters and result, in VM notation. 

455 _lineNumber: Nullable[int] #: The method's first source line, if the report says. 

456 

457 def __init__( 

458 self, 

459 name: str, 

460 descriptor: str, 

461 lineNumber: Nullable[int] = None, 

462 *, 

463 parent: Nullable[Class] = None 

464 ) -> None: 

465 """ 

466 Initialize the method, and add it to the methods of its class. 

467 

468 Its counters are added by creating them with this method as their parent. 

469 

470 :param name: Name of the method, e.g. ``absolute``; ``<init>`` for a constructor. 

471 :param descriptor: Descriptor of the method, e.g. ``(I)I`` for a method taking and returning an ``int``. 

472 :param lineNumber: Optional, the method's first source line. Default: ``None``. 

473 :param parent: Optional, the class the method belongs to; the method is added to its methods by 

474 :attr:`Name` and :attr:`Descriptor`. Default: ``None``. 

475 :raises ValueError: If parameter ``name`` is ``None``. 

476 :raises TypeError: If parameter ``name`` isn't of type :class:`str`. 

477 :raises ValueError: If parameter ``name`` is empty. 

478 :raises ValueError: If parameter ``descriptor`` is ``None``. 

479 :raises TypeError: If parameter ``descriptor`` isn't of type :class:`str`. 

480 :raises ValueError: If parameter ``descriptor`` is empty. 

481 :raises TypeError: If parameter ``lineNumber`` isn't of type :class:`int`. 

482 :raises ValueError: If parameter ``lineNumber`` is less than 1. 

483 :raises TypeError: If parameter ``parent`` isn't of type :class:`Class`. 

484 """ 

485 super().__init__(name) 

486 CountersMixin.__init__(self) 

487 

488 if name == "": 488 ↛ 489line 488 didn't jump to line 489 because the condition on line 488 was never true

489 raise ValueError("Parameter 'name' is empty.") 

490 

491 if descriptor is None: 

492 raise ValueError("Parameter 'descriptor' is None.") 

493 elif not isinstance(descriptor, str): 493 ↛ 494line 493 didn't jump to line 494 because the condition on line 493 was never true

494 ex = TypeError("Parameter 'descriptor' is not of type 'str'.") 

495 ex.add_note(f"Got type '{getFullyQualifiedName(descriptor)}'.") 

496 raise ex 

497 elif descriptor == "": 

498 raise ValueError("Parameter 'descriptor' is empty.") 

499 

500 if lineNumber is not None: 

501 if not isinstance(lineNumber, int): 501 ↛ 502line 501 didn't jump to line 502 because the condition on line 501 was never true

502 ex = TypeError("Parameter 'lineNumber' is not of type 'int'.") 

503 ex.add_note(f"Got type '{getFullyQualifiedName(lineNumber)}'.") 

504 raise ex 

505 elif lineNumber < 1: 

506 ex = ValueError("Parameter 'lineNumber' is less than 1.") 

507 ex.add_note(f"Got value '{lineNumber}'.") 

508 raise ex 

509 

510 if parent is not None and not isinstance(parent, Class): 

511 ex = TypeError("Parameter 'parent' is not of type 'Class'.") 

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

513 raise ex 

514 

515 self._parent = parent 

516 self._descriptor = descriptor 

517 self._lineNumber = lineNumber 

518 

519 if parent is not None: 

520 parent._methods[f"{self._name}{self._descriptor}"] = self 

521 

522 @classmethod 

523 def Parse(cls, element: _Element, *, parent: Nullable[Class] = None) -> Self: 

524 """ 

525 Parse a method and its counters from its ``<method>`` element. 

526 

527 :param element: The ``<method>`` element. 

528 :param parent: Optional, the class the method belongs to. Default: ``None``. 

529 :returns: The method. 

530 :raises CodeCoverageError: If the method states a counter twice. 

531 """ 

532 method = cls( 

533 element.attrib["name"], 

534 element.attrib["desc"], 

535 int(line) if (line := element.attrib.get("line")) is not None else None, 

536 parent=parent 

537 ) 

538 

539 for counterElement in element.iterfind("counter"): 

540 if (counterType := CounterType(counterElement.attrib["type"])) in method._counters: 

541 raise CodeCoverageError( 

542 f"JaCoCo method '{method._name}{method._descriptor}' states counter '{counterType}' twice." 

543 ) 

544 

545 Counter.Parse(counterElement, parent=method) 

546 

547 return method 

548 

549 @readonly 

550 def Parent(self) -> Nullable[Class]: 

551 """ 

552 Read-only property to access the class the method belongs to (:attr:`_parent`). 

553 

554 :returns: The class; ``None`` if the method belongs to no class. 

555 """ 

556 return self._parent 

557 

558 @readonly 

559 def Descriptor(self) -> str: 

560 """ 

561 Read-only property to access the descriptor of the method (:attr:`_descriptor`). 

562 

563 The descriptor ``([Ljava/lang/String;)V`` is of a method taking a ``String[]`` and returning ``void``. 

564 

565 :returns: The descriptor, in VM notation. 

566 """ 

567 return self._descriptor 

568 

569 @readonly 

570 def LineNumber(self) -> Nullable[int]: 

571 """ 

572 Read-only property to access the method's first source line (:attr:`_lineNumber`). 

573 

574 A method of a class compiled without debug information has none. 

575 

576 :returns: The line number, or ``None`` if the report doesn't say. 

577 """ 

578 return self._lineNumber 

579 

580 

581@export 

582class Class(Base, CountersMixin): 

583 """ 

584 A ``<class>`` of a package: its source file, its methods and its counters. 

585 """ 

586 

587 _parent: Nullable[Package] #: The package the class belongs to. 

588 _sourceFileName: Nullable[str] #: Name of the class' source file, if the report says. 

589 _methods: dict[str, Method] #: The methods, by name and descriptor. 

590 

591 def __init__(self, name: str, sourceFileName: Nullable[str] = None, *, parent: Nullable[Package] = None) -> None: 

592 """ 

593 Initialize the class, and add it to the classes of its package. 

594 

595 Its methods and counters are added by creating them with this class as their parent. 

596 

597 :param name: Fully qualified name of the class in VM notation, e.g. ``my/pack/MyClass``. 

598 :param sourceFileName: Optional, name of the class' source file, e.g. ``MyClass.java``. Default: ``None``. 

599 :param parent: Optional, the package the class belongs to; the class is added to its classes by 

600 :attr:`Name`. Default: ``None``. 

601 :raises ValueError: If parameter ``name`` is ``None``. 

602 :raises TypeError: If parameter ``name`` isn't of type :class:`str`. 

603 :raises ValueError: If parameter ``name`` is empty. 

604 :raises TypeError: If parameter ``sourceFileName`` isn't of type :class:`str`. 

605 :raises TypeError: If parameter ``parent`` isn't of type :class:`Package`. 

606 """ 

607 super().__init__(name) 

608 CountersMixin.__init__(self) 

609 

610 if name == "": 

611 raise ValueError("Parameter 'name' is empty.") 

612 

613 if sourceFileName is not None and not isinstance(sourceFileName, str): 

614 ex = TypeError("Parameter 'sourceFileName' is not of type 'str'.") 

615 ex.add_note(f"Got type '{getFullyQualifiedName(sourceFileName)}'.") 

616 raise ex 

617 

618 if parent is not None and not isinstance(parent, Package): 

619 ex = TypeError("Parameter 'parent' is not of type 'Package'.") 

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

621 raise ex 

622 

623 self._parent = parent 

624 self._sourceFileName = sourceFileName 

625 self._methods = {} 

626 

627 if parent is not None: 

628 parent._classes[self._name] = self 

629 

630 @classmethod 

631 def Parse(cls, element: _Element, *, parent: Nullable[Package] = None) -> Self: 

632 """ 

633 Parse a class, its methods and counters from its ``<class>`` element. 

634 

635 :param element: The ``<class>`` element. 

636 :param parent: Optional, the package the class belongs to. Default: ``None``. 

637 :returns: The class. 

638 :raises CodeCoverageError: If the class states a method twice. 

639 :raises CodeCoverageError: If the class states a counter twice. 

640 """ 

641 klass = cls(element.attrib["name"], element.attrib.get("sourcefilename"), parent=parent) 

642 

643 for methodElement in element.iterfind("method"): 

644 if (key := f"{methodElement.attrib['name']}{methodElement.attrib['desc']}") in klass._methods: 

645 raise CodeCoverageError(f"JaCoCo class '{klass._name}' states method '{key}' twice.") 

646 

647 Method.Parse(methodElement, parent=klass) 

648 

649 for counterElement in element.iterfind("counter"): 

650 if (counterType := CounterType(counterElement.attrib["type"])) in klass._counters: 650 ↛ 651line 650 didn't jump to line 651 because the condition on line 650 was never true

651 raise CodeCoverageError(f"JaCoCo class '{klass._name}' states counter '{counterType}' twice.") 

652 

653 Counter.Parse(counterElement, parent=klass) 

654 

655 return klass 

656 

657 @readonly 

658 def Parent(self) -> Nullable[Package]: 

659 """ 

660 Read-only property to access the package the class belongs to (:attr:`_parent`). 

661 

662 :returns: The package; ``None`` if the class belongs to no package. 

663 """ 

664 return self._parent 

665 

666 @readonly 

667 def SourceFileName(self) -> Nullable[str]: 

668 """ 

669 Read-only property to access the name of the class' source file (:attr:`_sourceFileName`). 

670 

671 A class compiled without debug information names no source file. 

672 

673 :returns: The name of a :class:`SourceFile` of the class' package, or ``None`` if the report doesn't say. 

674 """ 

675 return self._sourceFileName 

676 

677 @readonly 

678 def Methods(self) -> dict[str, Method]: 

679 """ 

680 Read-only property to access the methods (:attr:`_methods`). 

681 

682 A method is keyed by its name and descriptor, e.g. ``absolute(I)I``. 

683 

684 :returns: The methods, by name and descriptor. 

685 """ 

686 return self._methods 

687 

688 

689@export 

690class Line(metaclass=ExtendedType, slots=True): 

691 """ 

692 A ``<line>`` of a source file: its missed and covered instructions and branches. 

693 """ 

694 

695 _parent: Nullable[SourceFile] #: The source file the line belongs to. 

696 _lineNumber: int #: Line number, counted from 1. 

697 _missedInstructionCount: Nullable[int] #: Number of missed instructions, if the report says. 

698 _coveredInstructionCount: Nullable[int] #: Number of covered instructions, if the report says. 

699 _missedBranchCount: Nullable[int] #: Number of missed branches, if the report says. 

700 _coveredBranchCount: Nullable[int] #: Number of covered branches, if the report says. 

701 

702 def __init__( 

703 self, 

704 lineNumber: int, 

705 missedInstructionCount: Nullable[int] = None, 

706 coveredInstructionCount: Nullable[int] = None, 

707 missedBranchCount: Nullable[int] = None, 

708 coveredBranchCount: Nullable[int] = None, 

709 *, 

710 parent: Nullable[SourceFile] = None 

711 ) -> None: 

712 """ 

713 Initialize the line, and add it to the lines of its source file. 

714 

715 :param lineNumber: Line number, counted from 1. 

716 :param missedInstructionCount: Optional, number of missed instructions. Default: ``None``. 

717 :param coveredInstructionCount: Optional, number of covered instructions. Default: ``None``. 

718 :param missedBranchCount: Optional, number of missed branches. Default: ``None``. 

719 :param coveredBranchCount: Optional, number of covered branches. Default: ``None``. 

720 :param parent: Optional, the source file the line belongs to; the line is added to its lines by 

721 :attr:`LineNumber`. Default: ``None``. 

722 :raises ValueError: If parameter ``lineNumber`` is ``None``. 

723 :raises TypeError: If parameter ``lineNumber`` isn't of type :class:`int`. 

724 :raises ValueError: If parameter ``lineNumber`` is less than 1. 

725 :raises TypeError: If parameter ``missedInstructionCount`` isn't of type :class:`int`. 

726 :raises ValueError: If parameter ``missedInstructionCount`` is negative. 

727 :raises TypeError: If parameter ``coveredInstructionCount`` isn't of type :class:`int`. 

728 :raises ValueError: If parameter ``coveredInstructionCount`` is negative. 

729 :raises TypeError: If parameter ``missedBranchCount`` isn't of type :class:`int`. 

730 :raises ValueError: If parameter ``missedBranchCount`` is negative. 

731 :raises TypeError: If parameter ``coveredBranchCount`` isn't of type :class:`int`. 

732 :raises ValueError: If parameter ``coveredBranchCount`` is negative. 

733 :raises TypeError: If parameter ``parent`` isn't of type :class:`SourceFile`. 

734 """ 

735 if lineNumber is None: 

736 raise ValueError("Parameter 'lineNumber' is None.") 

737 elif not isinstance(lineNumber, int): 

738 ex = TypeError("Parameter 'lineNumber' is not of type 'int'.") 

739 ex.add_note(f"Got type '{getFullyQualifiedName(lineNumber)}'.") 

740 raise ex 

741 elif lineNumber < 1: 

742 ex = ValueError("Parameter 'lineNumber' is less than 1.") 

743 ex.add_note(f"Got value '{lineNumber}'.") 

744 raise ex 

745 

746 for name, count in ( 

747 ("missedInstructionCount", missedInstructionCount), 

748 ("coveredInstructionCount", coveredInstructionCount), 

749 ("missedBranchCount", missedBranchCount), 

750 ("coveredBranchCount", coveredBranchCount) 

751 ): 

752 if count is not None: 

753 if not isinstance(count, int): 

754 ex = TypeError(f"Parameter '{name}' is not of type 'int'.") 

755 ex.add_note(f"Got type '{getFullyQualifiedName(count)}'.") 

756 raise ex 

757 elif count < 0: 

758 ex = ValueError(f"Parameter '{name}' is negative.") 

759 ex.add_note(f"Got value '{count}'.") 

760 raise ex 

761 

762 if parent is not None and not isinstance(parent, SourceFile): 

763 ex = TypeError("Parameter 'parent' is not of type 'SourceFile'.") 

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

765 raise ex 

766 

767 self._parent = parent 

768 self._lineNumber = lineNumber 

769 self._missedInstructionCount = missedInstructionCount 

770 self._coveredInstructionCount = coveredInstructionCount 

771 self._missedBranchCount = missedBranchCount 

772 self._coveredBranchCount = coveredBranchCount 

773 

774 if parent is not None: 

775 parent._lines[self._lineNumber] = self 

776 

777 @classmethod 

778 def Parse(cls, element: _Element, *, parent: Nullable[SourceFile] = None) -> Self: 

779 """ 

780 Parse a line from its ``<line>`` element. 

781 

782 :param element: The ``<line>`` element. 

783 :param parent: Optional, the source file the line belongs to. Default: ``None``. 

784 :returns: The line. 

785 """ 

786 attributes = element.attrib 

787 return cls( 

788 int(attributes["nr"]), 

789 int(attributes["mi"]) if "mi" in attributes else None, 

790 int(attributes["ci"]) if "ci" in attributes else None, 

791 int(attributes["mb"]) if "mb" in attributes else None, 

792 int(attributes["cb"]) if "cb" in attributes else None, 

793 parent=parent 

794 ) 

795 

796 @readonly 

797 def Parent(self) -> Nullable[SourceFile]: 

798 """ 

799 Read-only property to access the source file the line belongs to (:attr:`_parent`). 

800 

801 :returns: The source file; ``None`` if the line belongs to no source file. 

802 """ 

803 return self._parent 

804 

805 @readonly 

806 def LineNumber(self) -> int: 

807 """ 

808 Read-only property to access the line number (:attr:`_lineNumber`). 

809 

810 :returns: The line number, counted from 1. 

811 """ 

812 return self._lineNumber 

813 

814 @readonly 

815 def MissedInstructionCount(self) -> Nullable[int]: 

816 """ 

817 Read-only property to access the number of missed instructions (:attr:`_missedInstructionCount`). 

818 

819 :returns: The number, or ``None`` if the report doesn't say. 

820 """ 

821 return self._missedInstructionCount 

822 

823 @readonly 

824 def CoveredInstructionCount(self) -> Nullable[int]: 

825 """ 

826 Read-only property to access the number of covered instructions (:attr:`_coveredInstructionCount`). 

827 

828 :returns: The number, or ``None`` if the report doesn't say. 

829 """ 

830 return self._coveredInstructionCount 

831 

832 @readonly 

833 def MissedBranchCount(self) -> Nullable[int]: 

834 """ 

835 Read-only property to access the number of missed branches (:attr:`_missedBranchCount`). 

836 

837 :returns: The number, or ``None`` if the report doesn't say. 

838 """ 

839 return self._missedBranchCount 

840 

841 @readonly 

842 def CoveredBranchCount(self) -> Nullable[int]: 

843 """ 

844 Read-only property to access the number of covered branches (:attr:`_coveredBranchCount`). 

845 

846 :returns: The number, or ``None`` if the report doesn't say. 

847 """ 

848 return self._coveredBranchCount 

849 

850 

851@export 

852class SourceFile(Base, CountersMixin): 

853 """ 

854 A ``<sourcefile>`` of a package: its lines and its counters. 

855 """ 

856 

857 _parent: Nullable[Package] #: The package the source file belongs to. 

858 _lines: dict[int, Line] #: The lines with instructions, by number. 

859 

860 def __init__(self, name: str, *, parent: Nullable[Package] = None) -> None: 

861 """ 

862 Initialize the source file, and add it to the source files of its package. 

863 

864 Its lines and counters are added by creating them with this source file as their parent. 

865 

866 :param name: Name of the source file, without the package's directory, e.g. ``MyClass.java``. 

867 :param parent: Optional, the package the source file belongs to; the source file is added to its source 

868 files by :attr:`Name`. Default: ``None``. 

869 :raises ValueError: If parameter ``name`` is ``None``. 

870 :raises TypeError: If parameter ``name`` isn't of type :class:`str`. 

871 :raises ValueError: If parameter ``name`` is empty. 

872 :raises TypeError: If parameter ``parent`` isn't of type :class:`Package`. 

873 """ 

874 super().__init__(name) 

875 CountersMixin.__init__(self) 

876 

877 if name == "": 

878 raise ValueError("Parameter 'name' is empty.") 

879 

880 if parent is not None and not isinstance(parent, Package): 

881 ex = TypeError("Parameter 'parent' is not of type 'Package'.") 

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

883 raise ex 

884 

885 self._parent = parent 

886 self._lines = {} 

887 

888 if parent is not None: 

889 parent._sourceFiles[self._name] = self 

890 

891 @classmethod 

892 def Parse(cls, element: _Element, *, parent: Nullable[Package] = None) -> Self: 

893 """ 

894 Parse a source file, its lines and counters from its ``<sourcefile>`` element. 

895 

896 :param element: The ``<sourcefile>`` element. 

897 :param parent: Optional, the package the source file belongs to. Default: ``None``. 

898 :returns: The source file. 

899 :raises CodeCoverageError: If the source file states a line twice. 

900 :raises CodeCoverageError: If the source file states a counter twice. 

901 """ 

902 sourceFile = cls(element.attrib["name"], parent=parent) 

903 

904 for lineElement in element.iterfind("line"): 

905 if (lineNumber := int(lineElement.attrib["nr"])) in sourceFile._lines: 

906 raise CodeCoverageError(f"JaCoCo source file '{sourceFile._name}' states line {lineNumber} twice.") 

907 

908 Line.Parse(lineElement, parent=sourceFile) 

909 

910 for counterElement in element.iterfind("counter"): 

911 if (counterType := CounterType(counterElement.attrib["type"])) in sourceFile._counters: 911 ↛ 912line 911 didn't jump to line 912 because the condition on line 911 was never true

912 raise CodeCoverageError(f"JaCoCo source file '{sourceFile._name}' states counter '{counterType}' twice.") 

913 

914 Counter.Parse(counterElement, parent=sourceFile) 

915 

916 return sourceFile 

917 

918 @readonly 

919 def Parent(self) -> Nullable[Package]: 

920 """ 

921 Read-only property to access the package the source file belongs to (:attr:`_parent`). 

922 

923 :returns: The package; ``None`` if the source file belongs to no package. 

924 """ 

925 return self._parent 

926 

927 @readonly 

928 def Lines(self) -> dict[int, Line]: 

929 """ 

930 Read-only property to access the lines with instructions (:attr:`_lines`). 

931 

932 :returns: The lines, by number. 

933 """ 

934 return self._lines 

935 

936 

937@export 

938class Package(Base, CountersMixin, Generic[ParentType]): 

939 """ 

940 A ``<package>`` of the report or of a group: its classes and source files, and its counters. 

941 """ 

942 

943 _parent: Nullable[ParentType] #: The report or group the package belongs to. 

944 _classes: dict[str, Class] #: The classes, by name. 

945 _sourceFiles: dict[str, SourceFile] #: The source files, by name. 

946 

947 def __init__(self, name: str, *, parent: Nullable[ParentType] = None) -> None: 

948 """ 

949 Initialize the package, and add it to the packages of its report or group. 

950 

951 Its classes, source files and counters are added by creating them with this package as their parent. 

952 

953 :param name: Name of the package in VM notation, e.g. ``my/pack``; empty for the default package. 

954 :param parent: Optional, the report or group the package belongs to; the package is added to its packages 

955 by :attr:`Name`. Default: ``None``. 

956 :raises ValueError: If parameter ``name`` is ``None``. 

957 :raises TypeError: If parameter ``name`` isn't of type :class:`str`. 

958 :raises TypeError: If parameter ``parent`` isn't of type :class:`Report` or :class:`Group`. 

959 """ 

960 super().__init__(name) 

961 CountersMixin.__init__(self) 

962 

963 if parent is not None and not isinstance(parent, (Report, Group)): 

964 ex = TypeError("Parameter 'parent' is not of type 'Report' or 'Group'.") 

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

966 raise ex 

967 

968 self._parent = parent 

969 self._classes = {} 

970 self._sourceFiles = {} 

971 

972 if parent is not None: 

973 parent._packages[self._name] = self 

974 

975 @classmethod 

976 def Parse(cls, element: _Element, *, parent: Nullable[ParentType] = None) -> Self: 

977 """ 

978 Parse a package, its classes, source files and counters from its ``<package>`` element. 

979 

980 :param element: The ``<package>`` element. 

981 :param parent: Optional, the report or group the package belongs to. Default: ``None``. 

982 :returns: The package. 

983 :raises CodeCoverageError: If the package states a class or source file twice. 

984 :raises CodeCoverageError: If the package states a counter twice. 

985 """ 

986 package = cls(element.attrib["name"], parent=parent) 

987 

988 for classElement in element.iterfind("class"): 

989 if (name := classElement.attrib["name"]) in package._classes: 

990 raise CodeCoverageError(f"JaCoCo package '{package._name}' states class '{name}' twice.") 

991 

992 Class.Parse(classElement, parent=package) 

993 

994 for sourceFileElement in element.iterfind("sourcefile"): 

995 if (name := sourceFileElement.attrib["name"]) in package._sourceFiles: 995 ↛ 996line 995 didn't jump to line 996 because the condition on line 995 was never true

996 raise CodeCoverageError(f"JaCoCo package '{package._name}' states source file '{name}' twice.") 

997 

998 SourceFile.Parse(sourceFileElement, parent=package) 

999 

1000 for counterElement in element.iterfind("counter"): 

1001 if (counterType := CounterType(counterElement.attrib["type"])) in package._counters: 1001 ↛ 1002line 1001 didn't jump to line 1002 because the condition on line 1001 was never true

1002 raise CodeCoverageError(f"JaCoCo package '{package._name}' states counter '{counterType}' twice.") 

1003 

1004 Counter.Parse(counterElement, parent=package) 

1005 

1006 return package 

1007 

1008 @readonly 

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

1010 """ 

1011 Read-only property to access the report or group the package belongs to (:attr:`_parent`). 

1012 

1013 :returns: The :class:`Report` or :class:`Group`; ``None`` if the package belongs to none. 

1014 """ 

1015 return self._parent 

1016 

1017 @readonly 

1018 def Classes(self) -> dict[str, Class]: 

1019 """ 

1020 Read-only property to access the classes (:attr:`_classes`). 

1021 

1022 A class is keyed by its name, e.g. ``my/pack/MyClass``, a nested class by its binary name, e.g. 

1023 ``my/pack/MyClass$Inner``. 

1024 

1025 :returns: The classes, by name. 

1026 """ 

1027 return self._classes 

1028 

1029 @readonly 

1030 def SourceFiles(self) -> dict[str, SourceFile]: 

1031 """ 

1032 Read-only property to access the source files (:attr:`_sourceFiles`). 

1033 

1034 A source file is keyed by its name, e.g. ``MyClass.java``. 

1035 

1036 :returns: The source files, by name. 

1037 """ 

1038 return self._sourceFiles 

1039 

1040 

1041@export 

1042class Group(Base, CountersMixin, Generic[ParentType]): 

1043 """ 

1044 A ``<group>`` of the report or of a group: its groups or packages, and its counters. 

1045 

1046 JaCoCo groups packages, e.g. by module, if Maven's goal ``report-aggregate`` or Ant's task ``report`` asks for it. 

1047 """ 

1048 

1049 _parent: Nullable[ParentType] #: The report or group the group belongs to. 

1050 _groups: dict[str, Group[Group]] #: The groups, by name. 

1051 _packages: dict[str, Package[Group]] #: The packages, by name. 

1052 

1053 def __init__(self, name: str, *, parent: Nullable[ParentType] = None) -> None: 

1054 """ 

1055 Initialize the group, and add it to the groups of its report or group. 

1056 

1057 Its groups, packages and counters are added by creating them with this group as their parent. 

1058 

1059 :param name: Name of the group, e.g. of a module. 

1060 :param parent: Optional, the report or group the group belongs to; the group is added to its groups by 

1061 :attr:`Name`. Default: ``None``. 

1062 :raises ValueError: If parameter ``name`` is ``None``. 

1063 :raises TypeError: If parameter ``name`` isn't of type :class:`str`. 

1064 :raises ValueError: If parameter ``name`` is empty. 

1065 :raises TypeError: If parameter ``parent`` isn't of type :class:`Report` or :class:`Group`. 

1066 """ 

1067 super().__init__(name) 

1068 CountersMixin.__init__(self) 

1069 

1070 if name == "": 

1071 raise ValueError("Parameter 'name' is empty.") 

1072 

1073 if parent is not None and not isinstance(parent, (Report, Group)): 

1074 ex = TypeError("Parameter 'parent' is not of type 'Report' or 'Group'.") 

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

1076 raise ex 

1077 

1078 self._parent = parent 

1079 self._groups = {} 

1080 self._packages = {} 

1081 

1082 if parent is not None: 

1083 parent._groups[self._name] = self 

1084 

1085 @classmethod 

1086 def Parse(cls, element: _Element, *, parent: Nullable[ParentType] = None) -> Self: 

1087 """ 

1088 Parse a group, its groups, packages and counters from its ``<group>`` element. 

1089 

1090 :param element: The ``<group>`` element. 

1091 :param parent: Optional, the report or group the group belongs to. Default: ``None``. 

1092 :returns: The group. 

1093 :raises CodeCoverageError: If the group states a group or package twice. 

1094 :raises CodeCoverageError: If the group states a counter twice. 

1095 """ 

1096 group = cls(element.attrib["name"], parent=parent) 

1097 

1098 for groupElement in element.iterfind("group"): 

1099 if (name := groupElement.attrib["name"]) in group._groups: 1099 ↛ 1100line 1099 didn't jump to line 1100 because the condition on line 1099 was never true

1100 raise CodeCoverageError(f"JaCoCo group '{group._name}' states group '{name}' twice.") 

1101 

1102 Group.Parse(groupElement, parent=group) 

1103 

1104 for packageElement in element.iterfind("package"): 

1105 if (name := packageElement.attrib["name"]) in group._packages: 

1106 raise CodeCoverageError(f"JaCoCo group '{group._name}' states package '{name}' twice.") 

1107 

1108 Package.Parse(packageElement, parent=group) 

1109 

1110 for counterElement in element.iterfind("counter"): 

1111 if (counterType := CounterType(counterElement.attrib["type"])) in group._counters: 1111 ↛ 1112line 1111 didn't jump to line 1112 because the condition on line 1111 was never true

1112 raise CodeCoverageError(f"JaCoCo group '{group._name}' states counter '{counterType}' twice.") 

1113 

1114 Counter.Parse(counterElement, parent=group) 

1115 

1116 return group 

1117 

1118 @readonly 

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

1120 """ 

1121 Read-only property to access the report or group the group belongs to (:attr:`_parent`). 

1122 

1123 :returns: The :class:`Report` or :class:`Group`; ``None`` if the group belongs to none. 

1124 """ 

1125 return self._parent 

1126 

1127 @readonly 

1128 def Groups(self) -> dict[str, Group[Group]]: 

1129 """ 

1130 Read-only property to access the groups (:attr:`_groups`). 

1131 

1132 :returns: The groups, by name. 

1133 """ 

1134 return self._groups 

1135 

1136 @readonly 

1137 def Packages(self) -> dict[str, Package[Group]]: 

1138 """ 

1139 Read-only property to access the packages (:attr:`_packages`). 

1140 

1141 :returns: The packages, by name. 

1142 """ 

1143 return self._packages 

1144 

1145 

1146@export 

1147class Report(CountersMixin, mixin=True): 

1148 """ 

1149 The root element ``<report>``: its name, the sessions, the groups or packages, and the counters. 

1150 

1151 The root of the format's model: a session, a group, a package or a counter names it as its parent. 

1152 """ 

1153 

1154 _name: Nullable[str] #: Name of the report, e.g. of the project measured. 

1155 _formatVersion: Nullable[_FormatVersion] #: Version of the report format. 

1156 _sessionInfos: list[SessionInfo] #: The sessions, which contributed execution data. 

1157 _groups: dict[str, Group[Report]] #: The groups, by name. 

1158 _packages: dict[str, Package[Report]] #: The packages, by name. 

1159 

1160 def __init__(self) -> None: 

1161 """ 

1162 Initialize an empty report. 

1163 """ 

1164 CountersMixin.__init__(self) 

1165 

1166 self._name = None 

1167 self._formatVersion = None 

1168 self._sessionInfos = [] 

1169 self._groups = {} 

1170 self._packages = {} 

1171 

1172 @readonly 

1173 def Name(self) -> Nullable[str]: 

1174 """ 

1175 Read-only property to access the name of the report (:attr:`_name`). 

1176 

1177 The name is chosen by the tool writing the report, e.g. the name of the project measured. 

1178 

1179 :returns: The name; ``None`` before the report was converted. 

1180 """ 

1181 return self._name 

1182 

1183 @readonly 

1184 def FormatVersion(self) -> Nullable[FormatVersion]: 

1185 """ 

1186 Read-only property to access the version of the report format (:attr:`_formatVersion`). 

1187 

1188 :returns: The format version; ``None`` before the report was analyzed. 

1189 """ 

1190 return self._formatVersion 

1191 

1192 @readonly 

1193 def SessionInfos(self) -> list[SessionInfo]: 

1194 """ 

1195 Read-only property to access the sessions, which contributed execution data (:attr:`_sessionInfos`). 

1196 

1197 :returns: The sessions, in the order the report lists them. 

1198 """ 

1199 return self._sessionInfos 

1200 

1201 @readonly 

1202 def Groups(self) -> dict[str, Group[Report]]: 

1203 """ 

1204 Read-only property to access the groups (:attr:`_groups`). 

1205 

1206 :returns: The groups, by name; empty, if the report has packages. 

1207 """ 

1208 return self._groups 

1209 

1210 @readonly 

1211 def Packages(self) -> dict[str, Package[Report]]: 

1212 """ 

1213 Read-only property to access the packages (:attr:`_packages`). 

1214 

1215 :returns: The packages, by name; empty, if the report has groups. 

1216 """ 

1217 return self._packages 

1218 

1219 

1220@export 

1221class Document(cc_Document, Report): 

1222 """ 

1223 A JaCoCo XML code coverage report: read into the format's model, and converted to the common model. 

1224 """ 

1225 

1226 _xmlDocument: Nullable[_ElementTree] #: The parsed and validated XML document, after :meth:`Analyze`. 

1227 

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

1229 """ 

1230 Initialize the report, and optionally read it. 

1231 

1232 :param xmlReportFile: Path to the JaCoCo XML file. 

1233 :param analyzeAndConvert: Optional, if true, analyze the file and convert its content. Default: ``False``. 

1234 """ 

1235 super().__init__(xmlReportFile) 

1236 Report.__init__(self) 

1237 

1238 self._xmlDocument = None 

1239 

1240 if analyzeAndConvert: 

1241 self.Analyze() 

1242 self.Convert() 

1243 

1244 def Analyze(self) -> None: 

1245 """ 

1246 Parse the XML file, read the format version from the DTD's public identifier, and validate the file against the 

1247 XML schema of that version (:data:`SCHEMAS`). 

1248 

1249 :raises CodeCoverageError: If the file doesn't exist. 

1250 :raises CodeCoverageError: If the file can't be read. 

1251 :raises CodeCoverageError: If the file isn't well-formed XML. 

1252 :raises CodeCoverageError: If the root element isn't ``<report>``. 

1253 :raises CodeCoverageError: If the file states no public identifier of a supported format version. 

1254 :raises CodeCoverageError: If the XML schema can't be located or parsed. 

1255 :raises CodeCoverageError: If the file isn't valid according to the XML schema. 

1256 """ 

1257 if not self._path.exists(): 

1258 raise CodeCoverageError(f"JaCoCo report file '{self._path}' does not exist.") \ 

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

1260 

1261 with Stopwatch() as sw: 

1262 try: 

1263 content = self._path.read_bytes() 

1264 except OSError as ex: 

1265 raise CodeCoverageError(f"Couldn't read JaCoCo report file '{self._path}'.") from ex 

1266 

1267 try: 

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

1269 except XMLSyntaxError as ex: 

1270 raise CodeCoverageError(f"XML syntax error in JaCoCo report file '{self._path}'.") from ex 

1271 

1272 rootElement: _Element = xmlDocument.getroot() 

1273 if rootElement.tag != "report": 

1274 ex = CodeCoverageError(f"Root element of '{self._path}' is not '<report>'.") 

1275 ex.add_note(f"Got root element '<{rootElement.tag}>'.") 

1276 raise ex 

1277 

1278 publicID = xmlDocument.docinfo.public_id 

1279 match = PUBLIC_IDENTIFIER.fullmatch(publicID) if publicID is not None else None 

1280 if match is None or match[1] not in [member.value for member in FormatVersion]: 

1281 ex = CodeCoverageError(f"JaCoCo report file '{self._path}' states an unsupported format version.") 

1282 ex.add_note(f"Got public identifier '{publicID}'." if publicID is not None else "Got no public identifier.") 

1283 ex.add_note(f"Supported format versions: {', '.join(member.value for member in FormatVersion)}.") 

1284 raise ex 

1285 

1286 formatVersion = FormatVersion(match[1]) 

1287 schemaFile = SCHEMAS[formatVersion] 

1288 try: 

1289 schemaResourceFile = getResourceFile(Resources, schemaFile) 

1290 except ToolingException as ex: 

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

1292 

1293 try: 

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

1295 except (XMLSyntaxError, XMLSchemaParseError) as ex: 

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

1297 

1298 if not xmlSchema.validate(xmlDocument): 

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

1300 for logEntry in xmlSchema.error_log: 

1301 ex.add_note(str(logEntry)) 

1302 raise ex 

1303 

1304 self._xmlDocument = xmlDocument 

1305 self._formatVersion = formatVersion 

1306 

1307 self._analysisDuration = sw.Duration 

1308 

1309 def Convert(self) -> None: 

1310 """ 

1311 Convert the parsed and validated XML document to the format's model. 

1312 

1313 :raises CodeCoverageError: If the XML file was not analyzed before. |br| 

1314 Call 'Document.Analyze()' or create the document using 

1315 'Document(path, analyzeAndConvert=True)'. 

1316 :raises CodeCoverageError: If an element states a child element twice. 

1317 """ 

1318 if self._xmlDocument is None: 

1319 ex = CodeCoverageError(f"JaCoCo report file '{self._path}' needs to be read and analyzed by an XML parser.") 

1320 ex.add_note("Call 'Document.Analyze()' or create the document using 'Document(path, analyzeAndConvert=True)'.") 

1321 raise ex 

1322 

1323 with Stopwatch() as sw: 

1324 root: _Element = self._xmlDocument.getroot() 

1325 self._name = root.attrib["name"] 

1326 

1327 for sessionInfoElement in root.iterfind("sessioninfo"): 

1328 SessionInfo.Parse(sessionInfoElement, parent=self) 

1329 

1330 for groupElement in root.iterfind("group"): 

1331 if (name := groupElement.attrib["name"]) in self._groups: 1331 ↛ 1332line 1331 didn't jump to line 1332 because the condition on line 1331 was never true

1332 raise CodeCoverageError(f"JaCoCo report '{self._name}' states group '{name}' twice.") 

1333 

1334 Group.Parse(groupElement, parent=self) 

1335 

1336 for packageElement in root.iterfind("package"): 

1337 if (name := packageElement.attrib["name"]) in self._packages: 1337 ↛ 1338line 1337 didn't jump to line 1338 because the condition on line 1337 was never true

1338 raise CodeCoverageError(f"JaCoCo report '{self._name}' states package '{name}' twice.") 

1339 

1340 Package.Parse(packageElement, parent=self) 

1341 

1342 for counterElement in root.iterfind("counter"): 

1343 if (counterType := CounterType(counterElement.attrib["type"])) in self._counters: 1343 ↛ 1344line 1343 didn't jump to line 1344 because the condition on line 1343 was never true

1344 raise CodeCoverageError(f"JaCoCo report '{self._name}' states counter '{counterType}' twice.") 

1345 

1346 Counter.Parse(counterElement, parent=self) 

1347 

1348 self._conversionDuration = sw.Duration 

1349 

1350 def ToCoverageSummary(self) -> CoverageSummary: 

1351 """ 

1352 Convert the format's model to the common model, and aggregate it. 

1353 

1354 The packages of all groups are converted. A source file's path is its package's name and its name. A line's 

1355 missed and covered branches become as many branches. A method spans its first line and the next lines of its file, 

1356 as many as its ``LINE`` counter counts; a class spans its methods. 

1357 

1358 :returns: The report's root of the common model, named after the report - or, if the report's 

1359 name is empty, after the report file. 

1360 :raises CodeCoverageError: If two packages of the same name state a source file of the same name. 

1361 :raises CodeCoverageError: If a file's path runs through another file. 

1362 """ 

1363 def iteratePackages(element: Report | Group) -> Generator[Package, None, None]: 

1364 """ 

1365 Nested function for recursion. 

1366 

1367 :param element: The report or a group. 

1368 :returns: A generator of the packages of the element and of its groups. 

1369 """ 

1370 for group in element._groups.values(): 

1371 yield from iteratePackages(group) 

1372 

1373 yield from element._packages.values() 

1374 

1375 def getStatus(element: CountersMixin, counterType: CounterType) -> LineCoverageStatus: 

1376 """ 

1377 Nested function returning the coverage state of a class or method by its ``CLASS`` or ``METHOD`` counter. 

1378 

1379 :param element: The class or method. 

1380 :param counterType: The counter's kind. 

1381 :returns: Covered, if the counter counts a covered item, otherwise uncovered; unknown, if the element 

1382 has no such counter. 

1383 """ 

1384 if (counter := element._counters.get(counterType)) is None: 1384 ↛ 1385line 1384 didn't jump to line 1385 because the condition on line 1384 was never true

1385 return LineCoverageStatus.Unknown 

1386 

1387 return LineCoverageStatus.Covered if counter._coveredCount > 0 else LineCoverageStatus.Uncovered 

1388 

1389 summary = CoverageSummary(self._name if self._name is not None and self._name != "" else self._path.stem) 

1390 

1391 for package in iteratePackages(self): 

1392 for sourceFile in package._sourceFiles.values(): 

1393 file = summary.GetOrAddFile(Path(package._name) / sourceFile._name) 

1394 for number in sorted(sourceFile._lines): 

1395 line = sourceFile._lines[number] 

1396 missedBranchCount = line._missedBranchCount if line._missedBranchCount is not None else 0 

1397 coveredBranchCount = line._coveredBranchCount if line._coveredBranchCount is not None else 0 

1398 branches = [cc_Branch(LineCoverageStatus.Covered) for _ in range(coveredBranchCount)] 

1399 branches.extend(cc_Branch(LineCoverageStatus.Uncovered) for _ in range(missedBranchCount)) 

1400 if line._coveredInstructionCount is None: 

1401 status = LineCoverageStatus.Unknown 

1402 elif line._coveredInstructionCount == 0: 

1403 status = LineCoverageStatus.Uncovered 

1404 elif missedBranchCount > 0: 

1405 status = LineCoverageStatus.PartiallyCovered 

1406 else: 

1407 status = LineCoverageStatus.Covered 

1408 cc_Line(number, status, None, branches, parent=file) 

1409 

1410 for package in iteratePackages(self): 

1411 parent: cc_Unit | CoverageSummary = summary 

1412 for part in (part for part in package._name.split("/") if part != ""): 

1413 parent = parent._units[part] if part in parent._units else cc_Package(part, parent=parent) 

1414 

1415 for klass in package._classes.values(): 

1416 file = None 

1417 if klass._sourceFileName is not None and klass._sourceFileName in package._sourceFiles: 

1418 file = summary.GetOrAddFile(Path(package._name) / klass._sourceFileName) 

1419 

1420 # per method with a first line in the file: its first and last line 

1421 spans: dict[str, tuple[cc_Line, cc_Line]] = {} 

1422 for key, method in klass._methods.items(): 

1423 if ( 

1424 file is None or method._lineNumber is None or method._lineNumber > file._lastLineNumber or 

1425 (startLine := file._lines[method._lineNumber]) is None 

1426 ): 

1427 continue 

1428 

1429 counter = method._counters.get(CounterType.Line) 

1430 lineCount = counter._missedCount + counter._coveredCount if counter is not None else 1 

1431 lines = list(islice(file.IterateLines(startLine), max(lineCount, 1))) 

1432 spans[key] = (startLine, lines[-1]) 

1433 

1434 if (classUnit := parent._units.get(name := klass._name.rpartition("/")[2])) is None: 1434 ↛ 1444line 1434 didn't jump to line 1444 because the condition on line 1434 was always true

1435 classUnit = cc_Class( 

1436 name, 

1437 file=file, 

1438 startLine=min((span[0] for span in spans.values()), key=lambda line: line._lineNumber, default=None), 

1439 endLine=max((span[1] for span in spans.values()), key=lambda line: line._lineNumber, default=None), 

1440 status=getStatus(klass, CounterType.Class), 

1441 parent=parent 

1442 ) 

1443 

1444 for key, method in klass._methods.items(): 

1445 if key not in classUnit._units: 1445 ↛ 1444line 1445 didn't jump to line 1444 because the condition on line 1445 was always true

1446 cc_Method( 

1447 key, 

1448 file=file, 

1449 startLine=spans[key][0] if key in spans else None, 

1450 endLine=spans[key][1] if key in spans else None, 

1451 status=getStatus(method, CounterType.Method), 

1452 parent=classUnit 

1453 ) 

1454 

1455 summary.Aggregate() 

1456 return summary