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

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

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 

77from lxml.etree import _Element, _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 

86from pyEDAA.Reports.CodeCoverage import CoverageSummary, Document as cc_Document, Line as cc_Line 

87from pyEDAA.Reports.CodeCoverage import LineCoverageStatus, Method as cc_Method, Package as cc_Package 

88from pyEDAA.Reports.CodeCoverage import Unit as cc_Unit 

89 

90 

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

92 

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

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

95 

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

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

98 

99 

100@export 

101class FormatVersion(StringEnum): 

102 """ 

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

104 """ 

105 

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

107 

108 

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

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

111_FormatVersion = FormatVersion 

112 

113SCHEMAS: dict[FormatVersion, str] = { 

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

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

116 

117 

118@export 

119class CounterType(StringEnum): 

120 """ 

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

122 """ 

123 

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

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

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

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

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

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

130 

131 

132@export 

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

134 """ 

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

136 """ 

137 

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

139 

140 def __init__(self) -> None: 

141 """ 

142 Initialize the counters to none. 

143 

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

145 """ 

146 self._counters = {} 

147 

148 @readonly 

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

150 """ 

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

152 

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

154 """ 

155 return self._counters 

156 

157 

158@export 

159@abstractclass 

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

161 """ 

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

163 

164 Packages and classes are named elements. 

165 """ 

166 

167 _name: str #: Name of the element. 

168 

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

170 """ 

171 Initialize the name of the element. 

172 

173 :param name: Name of the element. 

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

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

176 """ 

177 if name is None: 

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

179 elif not isinstance(name, str): 

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

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

182 raise ex 

183 

184 self._name = name 

185 

186 @readonly 

187 def Name(self) -> str: 

188 """ 

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

190 

191 :returns: The name. 

192 """ 

193 return self._name 

194 

195 

196@export 

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

198 """ 

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

200 """ 

201 

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

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

204 _missedCount: int #: Number of missed items. 

205 _coveredCount: int #: Number of covered items. 

206 

207 def __init__( 

208 self, 

209 counterType: CounterType, 

210 missedCount: int, 

211 coveredCount: int, 

212 *, 

213 parent: Nullable[CountersMixin] = None 

214 ) -> None: 

215 """ 

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

217 

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

219 :param missedCount: Number of missed items. 

220 :param coveredCount: Number of covered items. 

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

222 :attr:`Type`. Default: ``None``. 

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

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

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

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

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

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

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

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

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

232 """ 

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

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

235 elif not isinstance(counterType, CounterType): 

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

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

238 raise ex 

239 

240 if missedCount is None: 

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

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

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

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

245 raise ex 

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

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

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

249 raise ex 

250 

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

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

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

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

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

256 raise ex 

257 elif coveredCount < 0: 

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

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

260 raise ex 

261 

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

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

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

265 raise ex 

266 

267 self._parent = parent 

268 self._type = counterType 

269 self._missedCount = missedCount 

270 self._coveredCount = coveredCount 

271 

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

273 parent._counters[self._type] = self 

274 

275 @classmethod 

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

277 """ 

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

279 

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

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

282 :returns: The counter. 

283 """ 

284 return cls( 

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

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

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

288 parent=parent 

289 ) 

290 

291 @readonly 

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

293 """ 

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

295 

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

297 """ 

298 return self._parent 

299 

300 @readonly 

301 def Type(self) -> CounterType: 

302 """ 

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

304 

305 :returns: The kind of items. 

306 """ 

307 return self._type 

308 

309 @readonly 

310 def MissedCount(self) -> int: 

311 """ 

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

313 

314 :returns: The number of missed items. 

315 """ 

316 return self._missedCount 

317 

318 @readonly 

319 def CoveredCount(self) -> int: 

320 """ 

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

322 

323 :returns: The number of covered items. 

324 """ 

325 return self._coveredCount 

326 

327 

328@export 

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

330 """ 

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

332 """ 

333 

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

335 _sessionID: str #: ID of the session. 

336 _start: datetime #: Time the session started. 

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

338 

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

340 """ 

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

342 

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

344 :param start: Time the session started. 

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

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

347 Default: ``None``. 

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

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

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

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

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

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

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

355 """ 

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

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

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

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

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

361 raise ex 

362 

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

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

365 elif not isinstance(start, datetime): 

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

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

368 raise ex 

369 

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

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

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

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

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

375 raise ex 

376 

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

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

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

380 raise ex 

381 

382 self._parent = parent 

383 self._sessionID = sessionID 

384 self._start = start 

385 self._dump = dump 

386 

387 if parent is not None: 

388 parent._sessionInfos.append(self) 

389 

390 @classmethod 

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

392 """ 

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

394 

395 The times are stated in milliseconds since the epoch. 

396 

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

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

399 :returns: The session. 

400 """ 

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

402 return cls( 

403 element.attrib["id"], 

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

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

406 parent=parent 

407 ) 

408 

409 @readonly 

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

411 """ 

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

413 

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

415 """ 

416 return self._parent 

417 

418 @readonly 

419 def SessionID(self) -> str: 

420 """ 

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

422 

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

424 

425 :returns: The ID. 

426 """ 

427 return self._sessionID 

428 

429 @readonly 

430 def Start(self) -> datetime: 

431 """ 

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

433 

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

435 """ 

436 return self._start 

437 

438 @readonly 

439 def Dump(self) -> datetime: 

440 """ 

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

442 

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

444 """ 

445 return self._dump 

446 

447 

448@export 

449class Method(Base, CountersMixin): 

450 """ 

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

452 """ 

453 

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

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

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

457 

458 def __init__( 

459 self, 

460 name: str, 

461 descriptor: str, 

462 lineNumber: Nullable[int] = None, 

463 *, 

464 parent: Nullable[Class] = None 

465 ) -> None: 

466 """ 

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

468 

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

470 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

485 """ 

486 super().__init__(name) 

487 CountersMixin.__init__(self) 

488 

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

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

491 

492 if descriptor is None: 

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

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

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

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

497 raise ex 

498 elif descriptor == "": 

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

500 

501 if lineNumber is not None: 

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

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

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

505 raise ex 

506 elif lineNumber < 1: 

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

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

509 raise ex 

510 

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

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

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

514 raise ex 

515 

516 self._parent = parent 

517 self._descriptor = descriptor 

518 self._lineNumber = lineNumber 

519 

520 if parent is not None: 

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

522 

523 @classmethod 

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

525 """ 

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

527 

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

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

530 :returns: The method. 

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

532 """ 

533 method = cls( 

534 element.attrib["name"], 

535 element.attrib["desc"], 

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

537 parent=parent 

538 ) 

539 

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

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

542 raise CodeCoverageError( 

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

544 ) 

545 

546 Counter.Parse(counterElement, parent=method) 

547 

548 return method 

549 

550 @readonly 

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

552 """ 

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

554 

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

556 """ 

557 return self._parent 

558 

559 @readonly 

560 def Descriptor(self) -> str: 

561 """ 

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

563 

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

565 

566 :returns: The descriptor, in VM notation. 

567 """ 

568 return self._descriptor 

569 

570 @readonly 

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

572 """ 

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

574 

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

576 

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

578 """ 

579 return self._lineNumber 

580 

581 

582@export 

583class Class(Base, CountersMixin): 

584 """ 

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

586 """ 

587 

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

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

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

591 

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

593 """ 

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

595 

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

597 

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

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

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

601 :attr:`Name`. Default: ``None``. 

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

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

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

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

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

607 """ 

608 super().__init__(name) 

609 CountersMixin.__init__(self) 

610 

611 if name == "": 

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

613 

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

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

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

617 raise ex 

618 

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

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

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

622 raise ex 

623 

624 self._parent = parent 

625 self._sourceFileName = sourceFileName 

626 self._methods = {} 

627 

628 if parent is not None: 

629 parent._classes[self._name] = self 

630 

631 @classmethod 

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

633 """ 

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

635 

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

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

638 :returns: The class. 

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

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

641 """ 

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

643 

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

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

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

647 

648 Method.Parse(methodElement, parent=klass) 

649 

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

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

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

653 

654 Counter.Parse(counterElement, parent=klass) 

655 

656 return klass 

657 

658 @readonly 

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

660 """ 

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

662 

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

664 """ 

665 return self._parent 

666 

667 @readonly 

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

669 """ 

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

671 

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

673 

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

675 """ 

676 return self._sourceFileName 

677 

678 @readonly 

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

680 """ 

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

682 

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

684 

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

686 """ 

687 return self._methods 

688 

689 

690@export 

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

692 """ 

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

694 """ 

695 

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

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

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

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

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

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

702 

703 def __init__( 

704 self, 

705 lineNumber: int, 

706 missedInstructionCount: Nullable[int] = None, 

707 coveredInstructionCount: Nullable[int] = None, 

708 missedBranchCount: Nullable[int] = None, 

709 coveredBranchCount: Nullable[int] = None, 

710 *, 

711 parent: Nullable[SourceFile] = None 

712 ) -> None: 

713 """ 

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

715 

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

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

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

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

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

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

722 :attr:`LineNumber`. Default: ``None``. 

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

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

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

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

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

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

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

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

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

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

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

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

735 """ 

736 if lineNumber is None: 

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

738 elif not isinstance(lineNumber, int): 

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

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

741 raise ex 

742 elif lineNumber < 1: 

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

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

745 raise ex 

746 

747 for name, count in ( 

748 ("missedInstructionCount", missedInstructionCount), 

749 ("coveredInstructionCount", coveredInstructionCount), 

750 ("missedBranchCount", missedBranchCount), 

751 ("coveredBranchCount", coveredBranchCount) 

752 ): 

753 if count is not None: 

754 if not isinstance(count, int): 

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

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

757 raise ex 

758 elif count < 0: 

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

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

761 raise ex 

762 

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

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

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

766 raise ex 

767 

768 self._parent = parent 

769 self._lineNumber = lineNumber 

770 self._missedInstructionCount = missedInstructionCount 

771 self._coveredInstructionCount = coveredInstructionCount 

772 self._missedBranchCount = missedBranchCount 

773 self._coveredBranchCount = coveredBranchCount 

774 

775 if parent is not None: 

776 parent._lines[self._lineNumber] = self 

777 

778 @classmethod 

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

780 """ 

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

782 

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

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

785 :returns: The line. 

786 """ 

787 attributes = element.attrib 

788 return cls( 

789 int(attributes["nr"]), 

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

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

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

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

794 parent=parent 

795 ) 

796 

797 @readonly 

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

799 """ 

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

801 

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

803 """ 

804 return self._parent 

805 

806 @readonly 

807 def LineNumber(self) -> int: 

808 """ 

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

810 

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

812 """ 

813 return self._lineNumber 

814 

815 @readonly 

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

817 """ 

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

819 

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

821 """ 

822 return self._missedInstructionCount 

823 

824 @readonly 

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

826 """ 

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

828 

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

830 """ 

831 return self._coveredInstructionCount 

832 

833 @readonly 

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

835 """ 

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

837 

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

839 """ 

840 return self._missedBranchCount 

841 

842 @readonly 

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

844 """ 

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

846 

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

848 """ 

849 return self._coveredBranchCount 

850 

851 

852@export 

853class SourceFile(Base, CountersMixin): 

854 """ 

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

856 """ 

857 

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

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

860 

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

862 """ 

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

864 

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

866 

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

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

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

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

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

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

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

874 """ 

875 super().__init__(name) 

876 CountersMixin.__init__(self) 

877 

878 if name == "": 

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

880 

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

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

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

884 raise ex 

885 

886 self._parent = parent 

887 self._lines = {} 

888 

889 if parent is not None: 

890 parent._sourceFiles[self._name] = self 

891 

892 @classmethod 

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

894 """ 

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

896 

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

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

899 :returns: The source file. 

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

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

902 """ 

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

904 

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

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

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

908 

909 Line.Parse(lineElement, parent=sourceFile) 

910 

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

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

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

914 

915 Counter.Parse(counterElement, parent=sourceFile) 

916 

917 return sourceFile 

918 

919 @readonly 

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

921 """ 

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

923 

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

925 """ 

926 return self._parent 

927 

928 @readonly 

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

930 """ 

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

932 

933 :returns: The lines, by number. 

934 """ 

935 return self._lines 

936 

937 

938@export 

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

940 """ 

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

942 """ 

943 

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

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

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

947 

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

949 """ 

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

951 

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

953 

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

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

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

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

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

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

960 """ 

961 super().__init__(name) 

962 CountersMixin.__init__(self) 

963 

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

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

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

967 raise ex 

968 

969 self._parent = parent 

970 self._classes = {} 

971 self._sourceFiles = {} 

972 

973 if parent is not None: 

974 parent._packages[self._name] = self 

975 

976 @classmethod 

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

978 """ 

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

980 

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

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

983 :returns: The package. 

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

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

986 """ 

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

988 

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

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

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

992 

993 Class.Parse(classElement, parent=package) 

994 

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

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

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

998 

999 SourceFile.Parse(sourceFileElement, parent=package) 

1000 

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

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

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

1004 

1005 Counter.Parse(counterElement, parent=package) 

1006 

1007 return package 

1008 

1009 @readonly 

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

1011 """ 

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

1013 

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

1015 """ 

1016 return self._parent 

1017 

1018 @readonly 

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

1020 """ 

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

1022 

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

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

1025 

1026 :returns: The classes, by name. 

1027 """ 

1028 return self._classes 

1029 

1030 @readonly 

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

1032 """ 

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

1034 

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

1036 

1037 :returns: The source files, by name. 

1038 """ 

1039 return self._sourceFiles 

1040 

1041 

1042@export 

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

1044 """ 

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

1046 

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

1048 """ 

1049 

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

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

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

1053 

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

1055 """ 

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

1057 

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

1059 

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

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

1062 :attr:`Name`. Default: ``None``. 

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

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

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

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

1067 """ 

1068 super().__init__(name) 

1069 CountersMixin.__init__(self) 

1070 

1071 if name == "": 

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

1073 

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

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

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

1077 raise ex 

1078 

1079 self._parent = parent 

1080 self._groups = {} 

1081 self._packages = {} 

1082 

1083 if parent is not None: 

1084 parent._groups[self._name] = self 

1085 

1086 @classmethod 

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

1088 """ 

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

1090 

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

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

1093 :returns: The group. 

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

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

1096 """ 

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

1098 

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

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

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

1102 

1103 Group.Parse(groupElement, parent=group) 

1104 

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

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

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

1108 

1109 Package.Parse(packageElement, parent=group) 

1110 

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

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

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

1114 

1115 Counter.Parse(counterElement, parent=group) 

1116 

1117 return group 

1118 

1119 @readonly 

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

1121 """ 

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

1123 

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

1125 """ 

1126 return self._parent 

1127 

1128 @readonly 

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

1130 """ 

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

1132 

1133 :returns: The groups, by name. 

1134 """ 

1135 return self._groups 

1136 

1137 @readonly 

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

1139 """ 

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

1141 

1142 :returns: The packages, by name. 

1143 """ 

1144 return self._packages 

1145 

1146 

1147@export 

1148class Report(CountersMixin, mixin=True): 

1149 """ 

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

1151 

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

1153 """ 

1154 

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

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

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

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

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

1160 

1161 def __init__(self) -> None: 

1162 """ 

1163 Initialize an empty report. 

1164 """ 

1165 CountersMixin.__init__(self) 

1166 

1167 self._name = None 

1168 self._formatVersion = None 

1169 self._sessionInfos = [] 

1170 self._groups = {} 

1171 self._packages = {} 

1172 

1173 @readonly 

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

1175 """ 

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

1177 

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

1179 

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

1181 """ 

1182 return self._name 

1183 

1184 @readonly 

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

1186 """ 

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

1188 

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

1190 """ 

1191 return self._formatVersion 

1192 

1193 @readonly 

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

1195 """ 

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

1197 

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

1199 """ 

1200 return self._sessionInfos 

1201 

1202 @readonly 

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

1204 """ 

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

1206 

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

1208 """ 

1209 return self._groups 

1210 

1211 @readonly 

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

1213 """ 

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

1215 

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

1217 """ 

1218 return self._packages 

1219 

1220 

1221@export 

1222class Document(cc_Document, Report): 

1223 """ 

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

1225 """ 

1226 

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

1228 

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

1230 """ 

1231 Initialize the report, and optionally read it. 

1232 

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

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

1235 """ 

1236 super().__init__(xmlReportFile) 

1237 Report.__init__(self) 

1238 

1239 self._xmlDocument = None 

1240 

1241 if analyzeAndConvert: 

1242 self.Analyze() 

1243 self.Convert() 

1244 

1245 def Analyze(self) -> None: 

1246 """ 

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

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

1249 

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

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

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

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

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

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

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

1257 """ 

1258 if not self._path.exists(): 

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

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

1261 

1262 with Stopwatch() as sw: 

1263 try: 

1264 content = self._path.read_bytes() 

1265 except OSError as ex: 

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

1267 

1268 try: 

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

1270 except XMLSyntaxError as ex: 

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

1272 

1273 rootElement: _Element = xmlDocument.getroot() 

1274 if rootElement.tag != "report": 

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

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

1277 raise ex 

1278 

1279 publicID = xmlDocument.docinfo.public_id 

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

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

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

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

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

1285 raise ex 

1286 

1287 formatVersion = FormatVersion(match[1]) 

1288 schemaFile = SCHEMAS[formatVersion] 

1289 try: 

1290 schemaResourceFile = getResourceFile(Resources, schemaFile) 

1291 except ToolingException as ex: 

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

1293 

1294 try: 

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

1296 except (XMLSyntaxError, XMLSchemaParseError) as ex: 

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

1298 

1299 if not xmlSchema.validate(xmlDocument): 

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

1301 for logEntry in xmlSchema.error_log: 

1302 ex.add_note(str(logEntry)) 

1303 raise ex 

1304 

1305 self._xmlDocument = xmlDocument 

1306 self._formatVersion = formatVersion 

1307 

1308 self._analysisDuration = sw.Duration 

1309 

1310 def Convert(self) -> None: 

1311 """ 

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

1313 

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

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

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

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

1318 """ 

1319 if self._xmlDocument is None: 

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

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

1322 raise ex 

1323 

1324 with Stopwatch() as sw: 

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

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

1327 

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

1329 SessionInfo.Parse(sessionInfoElement, parent=self) 

1330 

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

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

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

1334 

1335 Group.Parse(groupElement, parent=self) 

1336 

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

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

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

1340 

1341 Package.Parse(packageElement, parent=self) 

1342 

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

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

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

1346 

1347 Counter.Parse(counterElement, parent=self) 

1348 

1349 self._conversionDuration = sw.Duration 

1350 

1351 def ToCoverageSummary(self) -> CoverageSummary: 

1352 """ 

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

1354 

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

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

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

1358 

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

1360 name is empty, after the report file. 

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

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

1363 """ 

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

1365 """ 

1366 Nested function for recursion. 

1367 

1368 :param element: The report or a group. 

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

1370 """ 

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

1372 yield from iteratePackages(group) 

1373 

1374 yield from element._packages.values() 

1375 

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

1377 """ 

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

1379 

1380 :param element: The class or method. 

1381 :param counterType: The counter's kind. 

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

1383 has no such counter. 

1384 """ 

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

1386 return LineCoverageStatus.Unknown 

1387 

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

1389 

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

1391 

1392 for package in iteratePackages(self): 

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

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

1395 for number in sorted(sourceFile._lines): 

1396 line = sourceFile._lines[number] 

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

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

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

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

1401 if line._coveredInstructionCount is None: 

1402 status = LineCoverageStatus.Unknown 

1403 elif line._coveredInstructionCount == 0: 

1404 status = LineCoverageStatus.Uncovered 

1405 elif missedBranchCount > 0: 

1406 status = LineCoverageStatus.PartiallyCovered 

1407 else: 

1408 status = LineCoverageStatus.Covered 

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

1410 

1411 for package in iteratePackages(self): 

1412 parent: cc_Unit | CoverageSummary = summary 

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

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

1415 

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

1417 file = None 

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

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

1420 

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

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

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

1424 if ( 

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

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

1427 ): 

1428 continue 

1429 

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

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

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

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

1434 

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

1436 classUnit = cc_Class( 

1437 name, 

1438 file=file, 

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

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

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

1442 parent=parent 

1443 ) 

1444 

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

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

1447 cc_Method( 

1448 key, 

1449 file=file, 

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

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

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

1453 parent=classUnit 

1454 ) 

1455 

1456 summary.Aggregate() 

1457 return summary