Coverage for pyEDAA/Reports/CodeCoverage/__init__.py: 96%

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

32Abstract data model of code coverage, the common model every report format is converted to. 

33 

34The model has two hierarchies over the same lines: 

35 

36* The **physical** hierarchy - :class:`CoverageSummary`, :class:`Directory`, :class:`File` - is built from the file 

37 paths a report names. A file holds its executable lines; every format has them. 

38* The **logical** hierarchy holds the language units a report names - :class:`Package`, :class:`Module`, 

39 :class:`SourceFile`, :class:`Class`, :class:`Function`, :class:`Method` -, each spanning its file from a first to a 

40 last :class:`Line`, so both hierarchies count the same lines. 

41 

42A :class:`Line` and a :class:`Branch` carry a :class:`LineCoverageStatus` and - if the report says - a count: how 

43often the line ran, or the branch was taken. :meth:`CoverageSummary.Aggregate` computes the counters of every file, 

44directory and unit. 

45 

46The report formats have models of their own, which convert to this one: 

47 

48.. seealso:: 

49 

50 :mod:`pyEDAA.Reports.CodeCoverage.Cobertura` 

51 |rarr| The Cobertura XML format, as written e.g. by coverage.py (``coverage xml``) or gcovr (``--cobertura``). 

52 :mod:`pyEDAA.Reports.CodeCoverage.CoveragePy` 

53 |rarr| coverage.py's JSON format (``coverage json``). 

54 :mod:`pyEDAA.Reports.CodeCoverage.GHDL` 

55 |rarr| GHDL's JSON coverage file (``ghdl -r --coverage``). 

56 :mod:`pyEDAA.Reports.CodeCoverage.UCIS` 

57 |rarr| The XML interchange format of the Accellera UCIS standard, as written e.g. by pyucis (``pyucis convert``). 

58 :mod:`pyEDAA.Reports.CodeCoverage.Gcov` 

59 |rarr| GCC's gcov JSON format (``gcov --json-format``). 

60 :mod:`pyEDAA.Reports.CodeCoverage.LCOV` 

61 |rarr| lcov's tracefile format, as written e.g. by lcov (``lcov --capture``) or llvm-cov 

62 (``llvm-cov export -format=lcov``). 

63 :mod:`pyEDAA.Reports.CodeCoverage.JaCoCo` 

64 |rarr| JaCoCo's XML format, as written e.g. by Gradle's task ``jacocoTestReport``. 

65 :mod:`pyEDAA.Reports.CodeCoverage.AldecUCDB` 

66 |rarr| Aldec's UCDB XML format, as exported by ``acdb2xml`` of Riviera-PRO or Active-HDL. 

67 :mod:`pyEDAA.Reports.CodeCoverage.QuestaSim` 

68 |rarr| QuestaSim's code coverage report XML (``vcover report -xml -details``). 

69 :mod:`pyEDAA.Reports.CodeCoverage.LLVM` 

70 |rarr| LLVM's JSON code coverage export (``llvm-cov export``), e.g. of Clang's source-based code coverage. 

71""" 

72from __future__ import annotations 

73 

74from datetime import timedelta 

75from enum import Enum 

76from pathlib import Path 

77from typing import Callable, ClassVar, Generator, Iterable, Optional as Nullable 

78 

79from pyTooling.Common import getFullyQualifiedName, getTypeName 

80from pyTooling.Decorators import export, readonly 

81from pyTooling.Exceptions import AlreadyInHierarchyError, DuplicateElementError 

82from pyTooling.MetaClasses import ExtendedType, abstractclass, abstractmethod 

83 

84from pyEDAA.Reports import ReportException 

85 

86 

87@export 

88class CodeCoverageError(ReportException): 

89 """Base-exception of the code coverage data model and its report formats.""" 

90 

91 

92@export 

93class AlreadyInCoverageReportError(CodeCoverageError, AlreadyInHierarchyError): 

94 """The exception is raised when an element is added to an element, but it already has a parent.""" 

95 

96 

97@export 

98class DuplicateDirectoryError(CodeCoverageError, DuplicateElementError): 

99 """The exception is raised when a directory already contains a directory or a file of the same name.""" 

100 

101 

102@export 

103class DuplicateFileError(CodeCoverageError, DuplicateElementError): 

104 """The exception is raised when a directory or a report already contains a file or a directory of the same name.""" 

105 

106 

107@export 

108class DuplicateUnitError(CodeCoverageError, DuplicateElementError): 

109 """The exception is raised when a unit or the report's root already contains a unit of the same name.""" 

110 

111 

112@export 

113class DuplicateLineError(CodeCoverageError, DuplicateElementError): 

114 """The exception is raised when a file already has a line of the same number.""" 

115 

116 

117@export 

118class LineCoverageStatus(Enum): 

119 """The coverage state of a line, a branch or a unit, as a report states it.""" 

120 

121 Unknown = 0 #: The report doesn't say. 

122 Uncovered = 1 #: The line never ran, the branch was never taken, the unit was never called. 

123 PartiallyCovered = 2 #: The line ran, but not all of its branches were taken. 

124 Covered = 3 #: The line ran, the branch was taken, the unit was called. 

125 Excluded = 4 #: Excluded from the measurement, e.g. by a pragma comment. 

126 

127 

128@export 

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

130 """ 

131 Base-class of every element of the code coverage model: a reference to the element containing it, and to the report's 

132 root. 

133 """ 

134 

135 _PARENT_TYPE: ClassVar[tuple[type, ...]] #: The types a parent may have; assigned by each class having a parent. 

136 

137 _parent: Nullable[Base] #: The element containing this one, or ``None``. 

138 _root: Nullable[CoverageSummary] #: The report's root, or ``None`` while the element isn't part of a report. 

139 

140 def __init__(self, *, parent: Nullable[Base] = None) -> None: 

141 """ 

142 Initialize an element with its parent and the parent's root. 

143 

144 The parent doesn't list the element yet: the class setting the field the parent stores it by - a name, a line 

145 number - adds it with ``parent._AddElement(self)``. 

146 

147 :param parent: Optional, the element containing this one. Default: ``None``. 

148 :raises TypeError: If parameter ``parent`` isn't of a type this class declares in :attr:`_PARENT_TYPE`. 

149 """ 

150 if parent is None: 

151 self._parent = None 

152 self._root = None 

153 elif not isinstance(parent, self._PARENT_TYPE): 

154 typeNames = " or ".join(f"'{parentType.__name__}'" for parentType in self._PARENT_TYPE) 

155 ex = TypeError(f"Parameter 'parent' is not of type {typeNames}.") 

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

157 raise ex 

158 else: 

159 self._parent = parent 

160 self._root = parent._root 

161 

162 @property 

163 def Parent(self) -> Nullable[Base]: 

164 """ 

165 Property to access the element containing this one (:attr:`_parent`). 

166 

167 Assigning a parent adds this element to it, and sets the parent's root as the root of this element and of every 

168 element below it. 

169 

170 :returns: The parent, or ``None``. 

171 :raises ValueError: If ``None`` is assigned. 

172 :raises TypeError: If the assigned parent isn't of a type this class declares in 

173 :attr:`_PARENT_TYPE`. 

174 :raises TypeError: If a parent is assigned to a :class:`CoverageSummary`, which has no parent. 

175 :raises AlreadyInCoverageReportError: If the element already has a parent. 

176 :raises DuplicateDirectoryError: If the assigned parent already contains a directory of this name. 

177 :raises DuplicateFileError: If the assigned parent already contains a file of this name. 

178 :raises DuplicateUnitError: If the assigned parent already contains a unit of this name. 

179 :raises DuplicateLineError: If the assigned parent already contains a line of this line number. 

180 """ 

181 return self._parent 

182 

183 @Parent.setter 

184 def Parent(self, parent: Base) -> None: 

185 if parent is None: 

186 raise ValueError("Parameter 'parent' is None.") 

187 elif not isinstance(parent, self._PARENT_TYPE): 

188 typeNames = " or ".join(f"'{parentType.__name__}'" for parentType in self._PARENT_TYPE) 

189 ex = TypeError(f"Parameter 'parent' is not of type {typeNames}.") 

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

191 raise ex 

192 elif self._parent is not None: 

193 raise AlreadyInCoverageReportError(f"{getTypeName(self)} already has a parent.") 

194 

195 parent._AddElement(self) 

196 self._parent = parent 

197 self._SetRoot(parent._root) 

198 

199 @readonly 

200 def Root(self) -> Nullable[CoverageSummary]: 

201 """ 

202 Read-only property to access the report's root (:attr:`_root`). 

203 

204 The root is maintained by :attr:`Parent`: assigning a parent sets it for this element and every element below it. 

205 

206 :returns: The root, or ``None`` while the element isn't part of a report. 

207 """ 

208 return self._root 

209 

210 def _SetRoot(self, root: Nullable[CoverageSummary]) -> None: 

211 """ 

212 Set the report's root of this element. 

213 

214 A class with child elements extends it to set the root of its children too. 

215 

216 :param root: The report's root, or ``None`` while the element isn't part of a report. 

217 """ 

218 self._root = root 

219 

220 

221@export 

222class BaseWithStatus(Base): 

223 """ 

224 Base-class of the elements with a coverage state and a count: lines, branches and units. 

225 """ 

226 

227 _status: LineCoverageStatus #: The coverage state. 

228 _coverageCount: Nullable[int] #: How often it ran, was taken or was called, if the report says. 

229 

230 def __init__(self, status: LineCoverageStatus, coverageCount: Nullable[int], *, parent: Nullable[Base]) -> None: 

231 """ 

232 Initialize the parent, the coverage state and the count. 

233 

234 A count of ``0`` is uncovered, a positive count covered. 

235 

236 :param status: The coverage state. 

237 :param coverageCount: How often the line ran, the branch was taken or the unit was called; ``None``, if the report 

238 doesn't say. 

239 :param parent: The element containing this one, or ``None``. 

240 :raises TypeError: If parameter ``parent`` isn't of a type the class declares in :attr:`_PARENT_TYPE`. 

241 :raises ValueError: If parameter ``status`` is ``None``. 

242 :raises TypeError: If parameter ``status`` isn't of type :class:`LineCoverageStatus`. 

243 :raises TypeError: If parameter ``coverageCount`` isn't of type :class:`int`. 

244 :raises ValueError: If parameter ``coverageCount`` is negative. 

245 :raises ValueError: If parameter ``coverageCount`` contradicts parameter ``status``. 

246 """ 

247 super().__init__(parent=parent) 

248 

249 if status is None: 

250 raise ValueError("Parameter 'status' is None.") 

251 elif not isinstance(status, LineCoverageStatus): 

252 ex = TypeError("Parameter 'status' is not of type 'LineCoverageStatus'.") 

253 ex.add_note(f"Got type '{getFullyQualifiedName(status)}'.") 

254 raise ex 

255 

256 if coverageCount is not None: 

257 if not isinstance(coverageCount, int): 

258 ex = TypeError("Parameter 'coverageCount' is not of type 'int'.") 

259 ex.add_note(f"Got type '{getFullyQualifiedName(coverageCount)}'.") 

260 raise ex 

261 elif coverageCount < 0: 

262 ex = ValueError("Parameter 'coverageCount' is negative.") 

263 ex.add_note(f"Got value '{coverageCount}'.") 

264 raise ex 

265 elif (coverageCount == 0 and status in (LineCoverageStatus.Covered, LineCoverageStatus.PartiallyCovered)) or \ 

266 (coverageCount > 0 and status is LineCoverageStatus.Uncovered): 

267 ex = ValueError("Parameter 'coverageCount' contradicts parameter 'status'.") 

268 ex.add_note(f"Got count '{coverageCount}' for status '{status.name}'.") 

269 raise ex 

270 

271 self._status = status 

272 self._coverageCount = coverageCount 

273 

274 @readonly 

275 def Status(self) -> LineCoverageStatus: 

276 """ 

277 Read-only property to access the coverage state (:attr:`_status`). 

278 

279 :returns: The coverage state; :attr:`LineCoverageStatus.Unknown`, if the report doesn't say. 

280 """ 

281 return self._status 

282 

283 @readonly 

284 def CoverageCount(self) -> Nullable[int]: 

285 """ 

286 Read-only property to access how often it ran, was taken or was called (:attr:`_coverageCount`). 

287 

288 :returns: The count, or ``None`` if the report doesn't say. 

289 """ 

290 return self._coverageCount 

291 

292 

293@export 

294class CoverageCountersMixin(metaclass=ExtendedType, mixin=True): 

295 """ 

296 A mixin-class adding the counters of lines and branches, computed from the lines of a file or a unit, or summed over 

297 a directory's children. 

298 

299 The counters are zero until :meth:`BaseWithPath.Aggregate` - of a directory or a file - or :meth:`Unit.Aggregate` 

300 computed them. 

301 """ 

302 

303 _totalLines: int #: Number of executable lines, without the excluded ones. 

304 _coveredLines: int #: Number of executable lines, which ran. 

305 _excludedLines: int #: Number of lines excluded from the measurement. 

306 _partialLines: int #: Number of lines, which ran without taking all their branches. 

307 _totalBranches: int #: Number of branches. 

308 _coveredBranches: int #: Number of branches, which were taken. 

309 

310 def __init__(self) -> None: 

311 """ 

312 Initialize the counters to zero. 

313 """ 

314 self._ResetCounters() 

315 

316 def _ResetCounters(self) -> None: 

317 """ 

318 Set the counters to zero. 

319 """ 

320 self._totalLines = 0 

321 self._coveredLines = 0 

322 self._excludedLines = 0 

323 self._partialLines = 0 

324 self._totalBranches = 0 

325 self._coveredBranches = 0 

326 

327 def _CountLines(self, lines: Iterable[Line]) -> None: 

328 """ 

329 Set the counters to the counts of lines. 

330 

331 A line's covered branches are those its :meth:`Line.Aggregate` computed. 

332 

333 :param lines: The lines. 

334 """ 

335 self._ResetCounters() 

336 for line in lines: 

337 if line._status is LineCoverageStatus.Excluded: 

338 self._excludedLines += 1 

339 continue 

340 

341 self._totalLines += 1 

342 self._totalBranches += len(line._branches) 

343 self._coveredBranches += line._coveredBranches 

344 if line._status is LineCoverageStatus.Covered: 

345 self._coveredLines += 1 

346 elif line._status is LineCoverageStatus.PartiallyCovered: 

347 self._coveredLines += 1 

348 self._partialLines += 1 

349 

350 @readonly 

351 def TotalLines(self) -> int: 

352 """ 

353 Read-only property to access the number of executable lines, without the excluded ones (:attr:`_totalLines`). 

354 

355 :returns: The number of executable lines. 

356 """ 

357 return self._totalLines 

358 

359 @readonly 

360 def CoveredLines(self) -> int: 

361 """ 

362 Read-only property to access the number of executable lines, which ran (:attr:`_coveredLines`). 

363 

364 :returns: The number of covered lines, including the partially covered ones. 

365 """ 

366 return self._coveredLines 

367 

368 @readonly 

369 def MissingLines(self) -> int: 

370 """ 

371 Read-only property to return the number of executable lines, which never ran. 

372 

373 :returns: :attr:`TotalLines` minus :attr:`CoveredLines`. 

374 """ 

375 return self._totalLines - self._coveredLines 

376 

377 @readonly 

378 def ExcludedLines(self) -> int: 

379 """ 

380 Read-only property to access the number of lines excluded from the measurement (:attr:`_excludedLines`). 

381 

382 :returns: The number of excluded lines. 

383 """ 

384 return self._excludedLines 

385 

386 @readonly 

387 def PartialLines(self) -> int: 

388 """ 

389 Read-only property to access the number of lines, which ran without taking all their branches 

390 (:attr:`_partialLines`). 

391 

392 :returns: The number of partially covered lines. 

393 """ 

394 return self._partialLines 

395 

396 @readonly 

397 def TotalBranches(self) -> int: 

398 """ 

399 Read-only property to access the number of branches (:attr:`_totalBranches`). 

400 

401 :returns: The number of branches. 

402 """ 

403 return self._totalBranches 

404 

405 @readonly 

406 def CoveredBranches(self) -> int: 

407 """ 

408 Read-only property to access the number of branches, which were taken (:attr:`_coveredBranches`). 

409 

410 :returns: The number of covered branches. 

411 """ 

412 return self._coveredBranches 

413 

414 @readonly 

415 def MissingBranches(self) -> int: 

416 """ 

417 Read-only property to return the number of branches, which were never taken. 

418 

419 :returns: :attr:`TotalBranches` minus :attr:`CoveredBranches`. 

420 """ 

421 return self._totalBranches - self._coveredBranches 

422 

423 @readonly 

424 def LineCoverage(self) -> float: 

425 """ 

426 Read-only property to return the line coverage: the ratio of covered to executable lines. 

427 

428 :returns: The line coverage in range 0.0..1.0; ``1.0`` if there is no executable line. 

429 """ 

430 return 1.0 if self._totalLines == 0 else self._coveredLines / self._totalLines 

431 

432 @readonly 

433 def BranchCoverage(self) -> float: 

434 """ 

435 Read-only property to return the branch coverage: the ratio of taken branches to all branches. 

436 

437 :returns: The branch coverage in range 0.0..1.0; ``1.0`` if there is no branch. 

438 """ 

439 return 1.0 if self._totalBranches == 0 else self._coveredBranches / self._totalBranches 

440 

441 @readonly 

442 def Coverage(self) -> float: 

443 """ 

444 Read-only property to return the combined coverage of lines and branches, as coverage.py computes it. 

445 

446 :returns: Covered lines plus taken branches, divided by executable lines plus branches, in range 0.0..1.0; 

447 ``1.0`` if there is neither. 

448 """ 

449 total = self._totalLines + self._totalBranches 

450 return 1.0 if total == 0 else (self._coveredLines + self._coveredBranches) / total 

451 

452 def _AggregateCounters(self, other: CoverageCountersMixin) -> None: 

453 """ 

454 Add the counters of another directory, file or unit to this one's. 

455 

456 :param other: The other directory, file or unit, aggregated. 

457 """ 

458 self._totalLines += other._totalLines 

459 self._coveredLines += other._coveredLines 

460 self._excludedLines += other._excludedLines 

461 self._partialLines += other._partialLines 

462 self._totalBranches += other._totalBranches 

463 self._coveredBranches += other._coveredBranches 

464 

465 

466@export 

467class BaseWithPath(Base, CoverageCountersMixin): 

468 """ 

469 Base-class of the physical hierarchy - directories and source files -: a name, a path below the report's root, and 

470 the counters. 

471 

472 Its parent is the :class:`Directory` containing it. 

473 """ 

474 

475 _name: str #: Name of the directory or file. 

476 

477 def __init__(self, name: str, *, parent: Nullable[Directory]) -> None: 

478 """ 

479 Initialize the parent, the name and the counters. 

480 

481 :param name: Name of the directory or file. 

482 :param parent: The directory containing this one, or ``None``. 

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

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

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

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

487 """ 

488 super().__init__(parent=parent) 

489 CoverageCountersMixin.__init__(self) 

490 

491 if name is None: 

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

493 elif not isinstance(name, str): 

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

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

496 raise ex 

497 elif name == "": 

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

499 

500 self._name = name 

501 

502 @readonly 

503 def Name(self) -> str: 

504 """ 

505 Read-only property to access the name of the directory or file (:attr:`_name`). 

506 

507 :returns: The name. 

508 """ 

509 return self._name 

510 

511 @readonly 

512 def Path(self) -> Path: 

513 """ 

514 Read-only property to return the path below the root: the names of the parent directories and the own name. 

515 

516 A file ``Counter.vhdl`` in a directory ``src`` has the path ``src/Counter.vhdl``. 

517 

518 :returns: The path; the name, if there is no parent. 

519 """ 

520 if self._parent is None: 

521 return Path(self._name) 

522 

523 return self._parent.Path / self._name 

524 

525 @abstractmethod 

526 def Aggregate(self) -> None: 

527 """ 

528 Compute the counters. 

529 """ 

530 

531 

532@export 

533class Directory(BaseWithPath): 

534 """ 

535 A directory: its directories and source files, and their summed counters. 

536 """ 

537 

538 _PARENT_TYPE: ClassVar[tuple[type, ...]] #: A directory is in a directory; assigned below the class. 

539 

540 _directories: dict[str, Directory] #: The directories in this directory, by name. 

541 _files: dict[str, File] #: The source files in this directory, by name. 

542 

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

544 """ 

545 Initialize a directory, and add it to its parent directory. 

546 

547 :param name: Name of the directory. 

548 :param parent: Optional, the directory containing this one. Default: ``None``. 

549 :raises TypeError: If parameter ``parent`` isn't of type :class:`Directory`. 

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

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

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

553 :raises DuplicateDirectoryError: If the parent directory already contains a file or directory of this name. 

554 """ 

555 super().__init__(name, parent=parent) 

556 

557 self._directories = {} 

558 self._files = {} 

559 

560 if parent is not None: 

561 parent._AddElement(self) 

562 

563 def _AddElement(self, element: Directory | File) -> None: 

564 """ 

565 Add a directory or file, which names this directory as its parent. 

566 

567 :param element: The directory or file. 

568 :raises DuplicateDirectoryError: If this directory already contains a directory of this name. 

569 :raises DuplicateFileError: If this directory already contains a file of this name. 

570 """ 

571 if isinstance(element, Directory): 

572 elements = self._directories 

573 exception = DuplicateDirectoryError 

574 else: 

575 elements = self._files 

576 exception = DuplicateFileError 

577 

578 if element._name in self._directories or element._name in self._files: 

579 raise exception(f"Directory '{self.Path.as_posix()}' already contains '{element._name}'.") 

580 

581 elements[element._name] = element 

582 

583 def _SetRoot(self, root: Nullable[CoverageSummary]) -> None: 

584 """ 

585 Set the report's root of this directory, and of the directories and files in it. 

586 

587 :param root: The report's root, or ``None`` while the directory isn't part of a report. 

588 """ 

589 super()._SetRoot(root) 

590 for directory in self._directories.values(): 

591 directory._SetRoot(root) 

592 

593 for file in self._files.values(): 

594 file._SetRoot(root) 

595 

596 @readonly 

597 def Directories(self) -> dict[str, Directory]: 

598 """ 

599 Read-only property to access the directories in this directory (:attr:`_directories`). 

600 

601 :returns: The directories, by name. 

602 """ 

603 return self._directories 

604 

605 @readonly 

606 def Files(self) -> dict[str, File]: 

607 """ 

608 Read-only property to access the source files in this directory (:attr:`_files`). 

609 

610 :returns: The files, by name. 

611 """ 

612 return self._files 

613 

614 @readonly 

615 def FileCount(self) -> int: 

616 """ 

617 Read-only property to return the number of source files in this directory and below. 

618 

619 :returns: The number of files. 

620 """ 

621 return len(self._files) + sum(directory.FileCount for directory in self._directories.values()) 

622 

623 def GetOrAddFile(self, path: Path | str) -> File: 

624 """ 

625 Return the source file at a path below this directory, adding it and the directories on its way, if missing. 

626 

627 A backslash in a string path separates directories too, as a report written on Windows has them. 

628 

629 :param path: The file's path relative to this directory, e.g. ``src/Counter.vhdl``. 

630 :returns: The file. 

631 :raises ValueError: If parameter ``path`` is ``None``. 

632 :raises TypeError: If parameter ``path`` isn't of type :class:`~pathlib.Path` or :class:`str`. 

633 :raises ValueError: If parameter ``path`` names no file. 

634 :raises CodeCoverageError: If a part of the path is a file instead of a directory, or the file's name is a 

635 directory's. 

636 """ 

637 if path is None: 637 ↛ 638line 637 didn't jump to line 638 because the condition on line 637 was never true

638 raise ValueError("Parameter 'path' is None.") 

639 elif isinstance(path, str): 

640 path = Path(path.replace("\\", "/")) 

641 elif not isinstance(path, Path): 

642 ex = TypeError("Parameter 'path' is not of type 'Path' or 'str'.") 

643 ex.add_note(f"Got type '{getFullyQualifiedName(path)}'.") 

644 raise ex 

645 

646 parts = [part for part in path.parts if part not in ("", ".", path.anchor)] 

647 if len(parts) == 0: 

648 ex = ValueError("Parameter 'path' names no file.") 

649 ex.add_note(f"Got path '{path}'.") 

650 raise ex 

651 

652 directory = self 

653 for part in parts[:-1]: 

654 if part in directory._files: 

655 raise CodeCoverageError(f"'{(directory.Path / part).as_posix()}' is a file, not a directory.") 

656 elif (subdirectory := directory._directories.get(part)) is None: 

657 subdirectory = Directory(part, parent=directory) 

658 directory = subdirectory 

659 

660 if (file := directory._files.get(parts[-1])) is None: 

661 file = File(parts[-1], parent=directory) 

662 

663 return file 

664 

665 def IterateFiles(self, predicate: Nullable[Callable[[File], bool]] = None) -> Generator[File, None, None]: 

666 """ 

667 Iterate all or selected source files in this directory and below. 

668 

669 A directory's files come before its directories' files, each in the order they were added. If parameter 

670 ``predicate`` is not None, the given filter function is used to skip files in the generator. 

671 

672 :param predicate: Optional, filter function accepting any file and returning a boolean. Default: ``None``. 

673 :returns: A generator of the files. 

674 """ 

675 if predicate is None: 

676 yield from self._files.values() 

677 for directory in self._directories.values(): 

678 yield from directory.IterateFiles() 

679 else: 

680 for file in self._files.values(): 

681 if predicate(file): 

682 yield file 

683 

684 for directory in self._directories.values(): 

685 yield from directory.IterateFiles(predicate) 

686 

687 def Aggregate(self) -> None: 

688 """ 

689 Aggregate the directories and files in this directory, then sum their counters. 

690 """ 

691 self._ResetCounters() 

692 for child in (*self._directories.values(), *self._files.values()): 

693 child.Aggregate() 

694 self._AggregateCounters(child) 

695 

696 def __repr__(self) -> str: 

697 """ 

698 Return a representation of the directory for debugging. 

699 

700 A directory ``src`` of 3 files reads ``<Directory src: 3 files, 75.0%>``. 

701 

702 :returns: The directory's path, its number of files and its line coverage. 

703 """ 

704 return f"<Directory {self.Path.as_posix()}: {self.FileCount} files, {self.LineCoverage:.1%}>" 

705 

706 

707Directory._PARENT_TYPE = (Directory, ) 

708 

709 

710@export 

711class CoverageSummary(Directory): 

712 """ 

713 The root of a code coverage report: the directory the report's file paths are relative to, and the top-level units 

714 of the logical hierarchy. 

715 

716 It is its own root. 

717 """ 

718 

719 _sourceDirectories: list[Path] #: The directories the report names as where the sources were, if any. 

720 _units: dict[str, Unit] #: The top-level units, by name. 

721 

722 def __init__(self, name: str, *, sourceDirectories: Nullable[Iterable[Path]] = None) -> None: 

723 """ 

724 Initialize the root of a code coverage report. 

725 

726 :param name: Name of the report, e.g. of the project measured. 

727 :param sourceDirectories: Optional, the directories the report names as where the sources were. Default: none. 

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

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

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

731 :raises TypeError: If parameter ``sourceDirectories`` isn't iterable. 

732 :raises TypeError: If parameter ``sourceDirectories`` contains an element not of type :class:`~pathlib.Path`. 

733 """ 

734 super().__init__(name) 

735 

736 self._root = self 

737 self._sourceDirectories = [] 

738 self._units = {} 

739 

740 if sourceDirectories is not None: 

741 if not isinstance(sourceDirectories, Iterable): 741 ↛ 742line 741 didn't jump to line 742 because the condition on line 741 was never true

742 ex = TypeError("Parameter 'sourceDirectories' is not iterable.") 

743 ex.add_note(f"Got type '{getFullyQualifiedName(sourceDirectories)}'.") 

744 raise ex 

745 

746 for sourceDirectory in sourceDirectories: 

747 if not isinstance(sourceDirectory, Path): 

748 ex = TypeError("Parameter 'sourceDirectories' contains an element not of type 'Path'.") 

749 ex.add_note(f"Got type '{getFullyQualifiedName(sourceDirectory)}'.") 

750 raise ex 

751 

752 self._sourceDirectories.append(sourceDirectory) 

753 

754 @Base.Parent.setter 

755 def Parent(self, parent: None) -> None: 

756 ex = TypeError(f"A '{getFullyQualifiedName(self)}' has no parent.") 

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

758 raise ex 

759 

760 def _AddElement(self, element: Directory | File | Unit) -> None: 

761 """ 

762 Add a top-level unit, directory or file, which names the report's root as its parent. 

763 

764 :param element: The unit, directory or file. 

765 :raises DuplicateUnitError: If the root already contains a unit of this name. 

766 :raises DuplicateDirectoryError: If the root already contains a directory of this name. 

767 :raises DuplicateFileError: If the root already contains a file of this name. 

768 """ 

769 if not isinstance(element, Unit): 

770 super()._AddElement(element) 

771 elif element._name in self._units: 

772 raise DuplicateUnitError(f"Unit '{element._name}' is added twice to '{self._name}'.") 

773 else: 

774 self._units[element._name] = element 

775 

776 @readonly 

777 def Path(self) -> Path: 

778 """ 

779 Read-only property to return the path below the root, which is the root's own: ``.``. 

780 

781 So the paths of the directories and files below are relative to the root, without its name. 

782 

783 :returns: ``.`` 

784 """ 

785 return Path(".") 

786 

787 @readonly 

788 def SourceDirectories(self) -> list[Path]: 

789 """ 

790 Read-only property to access the directories the report names as where the sources were 

791 (:attr:`_sourceDirectories`). 

792 

793 They are the paths on the machine the coverage was measured on, so they may not exist where the report is read. 

794 

795 :returns: The source directories; empty, if the report names none. 

796 """ 

797 return self._sourceDirectories 

798 

799 @readonly 

800 def Units(self) -> dict[str, Unit]: 

801 """ 

802 Read-only property to access the top-level units of the logical hierarchy (:attr:`_units`). 

803 

804 :returns: The units, by name. 

805 """ 

806 return self._units 

807 

808 def IterateUnits(self, predicate: Nullable[Callable[[Unit], bool]] = None) -> Generator[Unit, None, None]: 

809 """ 

810 Iterate all or selected units of the logical hierarchy. 

811 

812 A unit comes before its units, each level in the order the units were added. If parameter ``predicate`` is not None, 

813 the given filter function is used to skip units in the generator. A skipped unit's units are still iterated. 

814 

815 :param predicate: Optional, filter function accepting any unit and returning a boolean. Default: ``None``. 

816 :returns: A generator of the units. 

817 """ 

818 for unit in self._units.values(): 

819 yield from unit.IterateUnits(predicate) 

820 

821 def Aggregate(self) -> None: 

822 """ 

823 Aggregate the physical hierarchy - directories and files -, then the units of the logical hierarchy. 

824 """ 

825 super().Aggregate() 

826 for unit in self._units.values(): 

827 unit.Aggregate() 

828 

829 

830@export 

831class File(BaseWithPath): 

832 """ 

833 A source file: the coverage of its executable lines, and the units of the logical hierarchy it holds. 

834 

835 The lines are a list indexed by line number: index 0 is unused, and a line the report doesn't list - a comment, a 

836 declaration - is ``None``. Lines are iterated in order, and looked up without hashing. 

837 """ 

838 

839 _PARENT_TYPE: ClassVar[tuple[type, ...]] = (Directory, ) #: A file is in a directory. 

840 

841 _lines: list[Nullable[Line]] #: The executable lines by line number; ``None`` at index 0 and for gaps. 

842 _lastLineNumber: int #: The last line number the report lists; ``0``, if it lists none. 

843 _units: list[Unit] #: The units naming this file, in the order they were added. 

844 

845 def __init__(self, name: str, *, lines: Nullable[Iterable[Line]] = None, parent: Nullable[Directory] = None) -> None: 

846 """ 

847 Initialize a source file, and add it to its directory. 

848 

849 :param name: Name of the file. 

850 :param lines: Optional, the executable lines. Default: ``None``. 

851 :param parent: Optional, the directory containing the file. Default: ``None``. 

852 :raises TypeError: If parameter ``parent`` isn't of type :class:`Directory`. 

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

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

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

856 :raises DuplicateFileError: If the directory already contains a file or directory of this name. 

857 :raises TypeError: If parameter ``lines`` isn't iterable. 

858 :raises TypeError: If parameter ``lines`` contains an element not of type :class:`Line`. 

859 :raises DuplicateLineError: If two lines have the same number. 

860 """ 

861 super().__init__(name, parent=parent) 

862 

863 self._units = [] 

864 

865 if parent is not None: 

866 parent._AddElement(self) 

867 

868 if lines is None: 

869 self._lines = [None] 

870 self._lastLineNumber = 0 

871 elif not isinstance(lines, Iterable): 

872 ex = TypeError("Parameter 'lines' is not iterable.") 

873 ex.add_note(f"Got type '{getFullyQualifiedName(lines)}'.") 

874 raise ex 

875 else: 

876 # check the lines and find the last line number, then allocate the list once at its final size 

877 lineList = [] 

878 lastLineNumber = 0 

879 for line in lines: 

880 if not isinstance(line, Line): 

881 ex = TypeError("Parameter 'lines' contains an element not of type 'Line'.") 

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

883 raise ex 

884 

885 lineList.append(line) 

886 if line._lineNumber > lastLineNumber: 

887 lastLineNumber = line._lineNumber 

888 

889 fileLines = [None] * (lastLineNumber + 1) 

890 for line in lineList: 

891 lineNumber = line._lineNumber 

892 if fileLines[lineNumber] is not None: 

893 raise DuplicateLineError(f"Line {lineNumber} of file '{self.Path.as_posix()}' is added twice.") 

894 

895 fileLines[lineNumber] = line 

896 line._parent = self 

897 line._SetRoot(self._root) 

898 

899 self._lines = fileLines 

900 self._lastLineNumber = lastLineNumber 

901 

902 def _AddElement(self, line: Line) -> None: 

903 """ 

904 Add a line, which names this file as its parent. 

905 

906 :param line: The line. 

907 :raises DuplicateLineError: If the file already has a line of this number. 

908 """ 

909 lineNumber = line._lineNumber 

910 if (gap := lineNumber - len(self._lines)) >= 0: 

911 if gap > 0: 

912 self._lines.extend([None] * gap) 

913 

914 self._lines.append(line) 

915 elif self._lines[lineNumber] is None: 

916 self._lines[lineNumber] = line 

917 else: 

918 raise DuplicateLineError(f"Line {lineNumber} of file '{self.Path.as_posix()}' is added twice.") 

919 

920 if lineNumber > self._lastLineNumber: 

921 self._lastLineNumber = lineNumber 

922 

923 def _SetRoot(self, root: Nullable[CoverageSummary]) -> None: 

924 """ 

925 Set the report's root of this file, and of its lines. 

926 

927 :param root: The report's root, or ``None`` while the file isn't part of a report. 

928 """ 

929 super()._SetRoot(root) 

930 for line in self.IterateLines(): 

931 line._SetRoot(root) 

932 

933 @readonly 

934 def Lines(self) -> list[Nullable[Line]]: 

935 """ 

936 Read-only property to access the executable lines (:attr:`_lines`). 

937 

938 The list ends at the last line the report lists. To iterate only the listed lines, use :meth:`IterateLines`. 

939 

940 :returns: The lines, indexed by line number; ``None`` for index 0 and for a line the report doesn't list. 

941 """ 

942 return self._lines 

943 

944 @readonly 

945 def LastLineNumber(self) -> int: 

946 """ 

947 Read-only property to access the number of the last line the report lists (:attr:`_lastLineNumber`). 

948 

949 :returns: The line number; ``0``, if the report lists no line. 

950 """ 

951 return self._lastLineNumber 

952 

953 def GetLine(self, lineNumber: int) -> Nullable[Line]: 

954 """ 

955 Return the line of a line number. 

956 

957 :param lineNumber: The line number, counted from 1. 

958 :returns: The line, or ``None`` if the report doesn't list it. 

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

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

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

962 :raises ValueError: If parameter ``lineNumber`` is beyond the last line the report lists (:attr:`LastLineNumber`). 

963 """ 

964 if lineNumber is None: 

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

966 elif not isinstance(lineNumber, int): 

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

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

969 raise ex 

970 elif lineNumber < 1: 

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

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

973 raise ex 

974 elif lineNumber > self._lastLineNumber: 

975 ex = ValueError(f"Parameter 'lineNumber' is beyond the last line of file '{self.Path.as_posix()}'.") 

976 ex.add_note(f"Got value '{lineNumber}' for {self._lastLineNumber} lines.") 

977 raise ex from IndexError(f"Index {lineNumber} is out of range 1..{self._lastLineNumber}.") 

978 

979 return self._lines[lineNumber] 

980 

981 @readonly 

982 def Units(self) -> list[Unit]: 

983 """ 

984 Read-only property to access the units naming this file (:attr:`_units`). 

985 

986 A Python file is named by its module, its classes and its functions. 

987 

988 :returns: The units. 

989 """ 

990 return self._units 

991 

992 def IterateLines( 

993 self, 

994 startLine: Nullable[Line] = None, 

995 endLine: Nullable[Line] = None 

996 ) -> Generator[Line, None, None]: 

997 """ 

998 Iterate the executable lines of this file from a first to a last line, both included, by line number. 

999 

1000 :param startLine: Optional, the first line. Default: ``None``, the file's first line. 

1001 :param endLine: Optional, the last line. Default: ``None``, the file's last line. 

1002 :returns: A generator of the lines. 

1003 :raises TypeError: If parameter ``startLine`` isn't of type :class:`Line`. 

1004 :raises ValueError: If parameter ``startLine`` isn't a line of this file. 

1005 :raises TypeError: If parameter ``endLine`` isn't of type :class:`Line`. 

1006 :raises ValueError: If parameter ``endLine`` isn't a line of this file. 

1007 """ 

1008 if startLine is None: 

1009 first = 1 

1010 elif not isinstance(startLine, Line): 1010 ↛ 1011line 1010 didn't jump to line 1011 because the condition on line 1010 was never true

1011 ex = TypeError("Parameter 'startLine' is not of type 'Line'.") 

1012 ex.add_note(f"Got type '{getFullyQualifiedName(startLine)}'.") 

1013 raise ex 

1014 elif startLine._parent is not self: 

1015 raise ValueError(f"Parameter 'startLine' is not a line of file '{self.Path.as_posix()}'.") 

1016 else: 

1017 first = startLine._lineNumber 

1018 

1019 if endLine is None: 

1020 last = self._lastLineNumber 

1021 elif not isinstance(endLine, Line): 1021 ↛ 1022line 1021 didn't jump to line 1022 because the condition on line 1021 was never true

1022 ex = TypeError("Parameter 'endLine' is not of type 'Line'.") 

1023 ex.add_note(f"Got type '{getFullyQualifiedName(endLine)}'.") 

1024 raise ex 

1025 elif endLine._parent is not self: 1025 ↛ 1026line 1025 didn't jump to line 1026 because the condition on line 1025 was never true

1026 raise ValueError(f"Parameter 'endLine' is not a line of file '{self.Path.as_posix()}'.") 

1027 else: 

1028 last = endLine._lineNumber 

1029 

1030 for lineNumber in range(first, last + 1): 

1031 if (line := self._lines[lineNumber]) is not None: 

1032 yield line 

1033 

1034 def Aggregate(self) -> None: 

1035 """ 

1036 Aggregate the file's lines, then compute the counters from them. 

1037 """ 

1038 lines = list(self.IterateLines()) 

1039 for line in lines: 

1040 line.Aggregate() 

1041 

1042 self._CountLines(lines) 

1043 

1044 def __repr__(self) -> str: 

1045 """ 

1046 Return a representation of the file for debugging. 

1047 

1048 A file ``src/Counter.vhdl`` reads ``<File src/Counter.vhdl: 80.0%>``. 

1049 

1050 :returns: The file's path and line coverage. 

1051 """ 

1052 return f"<File {self.Path.as_posix()}: {self.LineCoverage:.1%}>" 

1053 

1054 

1055@export 

1056class Line(BaseWithStatus): 

1057 """ 

1058 The coverage of an executable line: its coverage state, how often it ran - if the report says -, and its branches. 

1059 

1060 A report lists only executable lines; a line it doesn't list - a comment, a declaration - has no :class:`Line`. Its 

1061 parent is the :class:`File` it is in. 

1062 """ 

1063 

1064 _PARENT_TYPE: ClassVar[tuple[type, ...]] = (File, ) #: A line is in a file. 

1065 

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

1067 _branches: list[Branch] #: The branches starting at this line. 

1068 _coveredBranches: int #: Number of branches, which were taken; zero until :meth:`Aggregate` computed it. 

1069 

1070 def __init__( 

1071 self, 

1072 lineNumber: int, 

1073 status: LineCoverageStatus, 

1074 coverageCount: Nullable[int] = None, 

1075 branches: Nullable[Iterable[Branch]] = None, 

1076 *, 

1077 parent: Nullable[File] = None 

1078 ) -> None: 

1079 """ 

1080 Initialize a line's coverage, and add it to its file. 

1081 

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

1083 :param status: Coverage state of the line. 

1084 :param coverageCount: Optional, how often the line ran, if the report says. Default: ``None``. 

1085 :param branches: Optional, the branches starting at this line. Default: ``None``. 

1086 :param parent: Optional, the file the line is in. Default: ``None``. 

1087 :raises TypeError: If parameter ``parent`` isn't of type :class:`File`. 

1088 :raises ValueError: If parameter ``status`` is ``None``. 

1089 :raises TypeError: If parameter ``status`` isn't of type :class:`LineCoverageStatus`. 

1090 :raises TypeError: If parameter ``coverageCount`` isn't of type :class:`int`. 

1091 :raises ValueError: If parameter ``coverageCount`` is negative. 

1092 :raises ValueError: If parameter ``coverageCount`` contradicts parameter ``status``. 

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

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

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

1096 :raises DuplicateLineError: If the file already has a line of this number. 

1097 :raises TypeError: If parameter ``branches`` isn't iterable. 

1098 :raises TypeError: If parameter ``branches`` contains an element not of type :class:`Branch`. 

1099 """ 

1100 super().__init__(status, coverageCount, parent=parent) 

1101 

1102 if lineNumber is None: 

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

1104 elif not isinstance(lineNumber, int): 

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

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

1107 raise ex 

1108 elif lineNumber < 1: 

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

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

1111 raise ex 

1112 

1113 self._lineNumber = lineNumber 

1114 self._branches = [] 

1115 self._coveredBranches = 0 

1116 

1117 if parent is not None: 

1118 parent._AddElement(self) 

1119 

1120 if branches is not None: 

1121 if not isinstance(branches, Iterable): 1121 ↛ 1122line 1121 didn't jump to line 1122 because the condition on line 1121 was never true

1122 ex = TypeError("Parameter 'branches' is not iterable.") 

1123 ex.add_note(f"Got type '{getFullyQualifiedName(branches)}'.") 

1124 raise ex 

1125 

1126 for branch in branches: 

1127 if not isinstance(branch, Branch): 

1128 ex = TypeError("Parameter 'branches' contains an element not of type 'Branch'.") 

1129 ex.add_note(f"Got type '{getFullyQualifiedName(branch)}'.") 

1130 raise ex 

1131 

1132 branch._parent = self 

1133 branch._root = self._root 

1134 self._branches.append(branch) 

1135 

1136 def _AddElement(self, branch: Branch) -> None: 

1137 """ 

1138 Add a branch, which names this line as its parent. 

1139 

1140 :param branch: The branch. 

1141 """ 

1142 self._branches.append(branch) 

1143 

1144 def _SetRoot(self, root: Nullable[CoverageSummary]) -> None: 

1145 """ 

1146 Set the report's root of this line, and of its branches. 

1147 

1148 :param root: The report's root, or ``None`` while the line isn't part of a report. 

1149 """ 

1150 super()._SetRoot(root) 

1151 for branch in self._branches: 

1152 branch._SetRoot(root) 

1153 

1154 @readonly 

1155 def LineNumber(self) -> int: 

1156 """ 

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

1158 

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

1160 """ 

1161 return self._lineNumber 

1162 

1163 @readonly 

1164 def Branches(self) -> list[Branch]: 

1165 """ 

1166 Read-only property to access the branches starting at this line (:attr:`_branches`). 

1167 

1168 :returns: The branches; empty for a line, which doesn't branch. 

1169 """ 

1170 return self._branches 

1171 

1172 @readonly 

1173 def CoveredBranches(self) -> int: 

1174 """ 

1175 Read-only property to access the number of this line's branches, which were taken (:attr:`_coveredBranches`). 

1176 

1177 :returns: The number of branches with state :attr:`LineCoverageStatus.Covered`; zero until :meth:`Aggregate` 

1178 computed it. 

1179 """ 

1180 return self._coveredBranches 

1181 

1182 def Aggregate(self) -> None: 

1183 """ 

1184 Compute the number of this line's branches, which were taken. 

1185 """ 

1186 self._coveredBranches = sum(1 for branch in self._branches if branch._status is LineCoverageStatus.Covered) 

1187 

1188 def __repr__(self) -> str: 

1189 """ 

1190 Return a representation of the line's coverage for debugging. 

1191 

1192 Line 12 with one of two branches taken reads ``<Line 12: PartiallyCovered (1/2 branches)>``. 

1193 

1194 :returns: The line number, the state and the branches. 

1195 """ 

1196 branches = f" ({self.CoveredBranches}/{len(self._branches)} branches)" if len(self._branches) > 0 else "" 

1197 return f"<Line {self._lineNumber}: {self._status.name}{branches}>" 

1198 

1199 

1200@export 

1201class Branch(BaseWithStatus): 

1202 """ 

1203 A branch of a line: whether it was taken, how often - if the report says -, and where it goes - if the report says. 

1204 

1205 Its parent is the :class:`Line` it starts at. 

1206 """ 

1207 

1208 _PARENT_TYPE: ClassVar[tuple[type, ...]] = (Line, ) #: A branch starts at a line. 

1209 

1210 _target: Nullable[Line] #: The line the branch goes to, if the report says. 

1211 

1212 def __init__( 

1213 self, 

1214 status: LineCoverageStatus, 

1215 coverageCount: Nullable[int] = None, 

1216 target: Nullable[Line] = None, 

1217 *, 

1218 parent: Nullable[Line] = None 

1219 ) -> None: 

1220 """ 

1221 Initialize a branch, and add it to its line. 

1222 

1223 :param status: Whether the branch was taken. 

1224 :param coverageCount: Optional, how often the branch was taken, if the report says. Default: ``None``. 

1225 :param target: Optional, the line the branch goes to, if the report says; ``None`` for an exit of a function. 

1226 Default: ``None``. 

1227 :param parent: Optional, the line the branch starts at. Default: ``None``. 

1228 :raises TypeError: If parameter ``parent`` isn't of type :class:`Line`. 

1229 :raises ValueError: If parameter ``status`` is ``None``. 

1230 :raises TypeError: If parameter ``status`` isn't of type :class:`LineCoverageStatus`. 

1231 :raises TypeError: If parameter ``coverageCount`` isn't of type :class:`int`. 

1232 :raises ValueError: If parameter ``coverageCount`` is negative. 

1233 :raises ValueError: If parameter ``coverageCount`` contradicts parameter ``status``. 

1234 :raises TypeError: If parameter ``target`` isn't of type :class:`Line`. 

1235 """ 

1236 super().__init__(status, coverageCount, parent=parent) 

1237 

1238 if target is not None and not isinstance(target, Line): 

1239 ex = TypeError("Parameter 'target' is not of type 'Line'.") 

1240 ex.add_note(f"Got type '{getFullyQualifiedName(target)}'.") 

1241 raise ex 

1242 

1243 self._target = target 

1244 

1245 if parent is not None: 

1246 parent._AddElement(self) 

1247 

1248 @readonly 

1249 def Target(self) -> Nullable[Line]: 

1250 """ 

1251 Read-only property to access the line the branch goes to (:attr:`_target`). 

1252 

1253 :returns: The line, or ``None`` if the report doesn't say or the branch exits the function. 

1254 """ 

1255 return self._target 

1256 

1257 

1258@export 

1259class Unit(BaseWithStatus, CoverageCountersMixin): 

1260 """ 

1261 Base-class of the logical hierarchy: a language unit - a package, a module, a class, a function, a method -, the 

1262 units it contains, and the lines it spans. 

1263 

1264 Its parent is the :class:`Unit` containing it, or the report's :class:`CoverageSummary`. A unit spans the lines of 

1265 its file from its first to its last line - a language construct wraps the constructs nested in it -, so the 

1266 physical and the logical hierarchy count the same lines. A unit without lines, e.g. a package of several files, 

1267 counts the lines of the units it contains, each line once. 

1268 """ 

1269 

1270 _PARENT_TYPE: ClassVar[tuple[type, ...]] #: A unit is in a unit or the report's root; assigned below the class. 

1271 

1272 _name: str #: Name of the unit. 

1273 _units: dict[str, Unit] #: The units this one contains, by name. 

1274 _file: Nullable[File] #: The source file the unit is in, if the report says. 

1275 _startLine: Nullable[Line] #: The unit's first line, if the report says. 

1276 _endLine: Nullable[Line] #: The unit's last line, if the report says. 

1277 

1278 def __init__( 

1279 self, 

1280 name: str, 

1281 *, 

1282 file: Nullable[File] = None, 

1283 startLine: Nullable[Line] = None, 

1284 endLine: Nullable[Line] = None, 

1285 status: LineCoverageStatus = LineCoverageStatus.Unknown, 

1286 coverageCount: Nullable[int] = None, 

1287 parent: Nullable[Unit | CoverageSummary] = None 

1288 ) -> None: 

1289 """ 

1290 Initialize a unit, and add it to its file and to the unit or report containing it. 

1291 

1292 :param name: Name of the unit. 

1293 :param file: Optional, the source file the unit is in. Default: ``None``. 

1294 :param startLine: Optional, the unit's first line. Default: ``None``. 

1295 :param endLine: Optional, the unit's last line. Default: ``None``. 

1296 :param status: Optional, whether the unit was called. Default: :attr:`LineCoverageStatus.Unknown`. 

1297 :param coverageCount: Optional, how often the unit was called. Default: ``None``. 

1298 :param parent: Optional, the unit or report containing this unit. Default: ``None``. 

1299 :raises TypeError: If parameter ``parent`` isn't of type :class:`Unit` or :class:`CoverageSummary`. 

1300 :raises ValueError: If parameter ``status`` is ``None``. 

1301 :raises TypeError: If parameter ``status`` isn't of type :class:`LineCoverageStatus`. 

1302 :raises TypeError: If parameter ``coverageCount`` isn't of type :class:`int`. 

1303 :raises ValueError: If parameter ``coverageCount`` is negative. 

1304 :raises ValueError: If parameter ``coverageCount`` contradicts parameter ``status``. 

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

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

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

1308 :raises TypeError: If parameter ``file`` isn't of type :class:`File`. 

1309 :raises TypeError: If parameter ``startLine`` isn't of type :class:`Line`. 

1310 :raises ValueError: If parameter ``startLine`` isn't a line of parameter ``file``. 

1311 :raises TypeError: If parameter ``endLine`` isn't of type :class:`Line`. 

1312 :raises ValueError: If parameter ``endLine`` isn't a line of parameter ``file``. 

1313 :raises ValueError: If parameter ``endLine`` is before parameter ``startLine``. 

1314 :raises DuplicateUnitError: If the parent already contains a unit of this name. 

1315 """ 

1316 super().__init__(status, coverageCount, parent=parent) 

1317 CoverageCountersMixin.__init__(self) 

1318 

1319 if name is None: 

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

1321 elif not isinstance(name, str): 

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

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

1324 raise ex 

1325 elif name == "": 

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

1327 

1328 if file is not None and not isinstance(file, File): 1328 ↛ 1329line 1328 didn't jump to line 1329 because the condition on line 1328 was never true

1329 ex = TypeError("Parameter 'file' is not of type 'File'.") 

1330 ex.add_note(f"Got type '{getFullyQualifiedName(file)}'.") 

1331 raise ex 

1332 

1333 if startLine is not None: 

1334 if not isinstance(startLine, Line): 

1335 ex = TypeError("Parameter 'startLine' is not of type 'Line'.") 

1336 ex.add_note(f"Got type '{getFullyQualifiedName(startLine)}'.") 

1337 raise ex 

1338 elif startLine._parent is not file: 

1339 raise ValueError("Parameter 'startLine' is not a line of parameter 'file'.") 

1340 

1341 if endLine is not None: 

1342 if not isinstance(endLine, Line): 1342 ↛ 1343line 1342 didn't jump to line 1343 because the condition on line 1342 was never true

1343 ex = TypeError("Parameter 'endLine' is not of type 'Line'.") 

1344 ex.add_note(f"Got type '{getFullyQualifiedName(endLine)}'.") 

1345 raise ex 

1346 elif endLine._parent is not file: 1346 ↛ 1347line 1346 didn't jump to line 1347 because the condition on line 1346 was never true

1347 raise ValueError("Parameter 'endLine' is not a line of parameter 'file'.") 

1348 elif startLine is not None and endLine._lineNumber < startLine._lineNumber: 

1349 ex = ValueError("Parameter 'endLine' is before parameter 'startLine'.") 

1350 ex.add_note(f"Got lines {startLine._lineNumber} to {endLine._lineNumber}.") 

1351 raise ex 

1352 

1353 self._name = name 

1354 self._units = {} 

1355 self._file = file 

1356 self._startLine = startLine 

1357 self._endLine = endLine 

1358 

1359 if parent is not None: 

1360 parent._AddElement(self) 

1361 

1362 if file is not None: 

1363 file._units.append(self) 

1364 

1365 def _AddElement(self, unit: Unit) -> None: 

1366 """ 

1367 Add a unit, which names this unit as its parent. 

1368 

1369 :param unit: The unit. 

1370 :raises DuplicateUnitError: If this unit already contains a unit of this name. 

1371 """ 

1372 if unit._name in self._units: 1372 ↛ 1373line 1372 didn't jump to line 1373 because the condition on line 1372 was never true

1373 raise DuplicateUnitError(f"Unit '{unit._name}' is added twice to '{self._name}'.") 

1374 

1375 self._units[unit._name] = unit 

1376 

1377 def _SetRoot(self, root: Nullable[CoverageSummary]) -> None: 

1378 """ 

1379 Set the report's root of this unit, and of the units it contains. 

1380 

1381 :param root: The report's root, or ``None`` while the unit isn't part of a report. 

1382 """ 

1383 super()._SetRoot(root) 

1384 for unit in self._units.values(): 

1385 unit._SetRoot(root) 

1386 

1387 @readonly 

1388 def Name(self) -> str: 

1389 """ 

1390 Read-only property to access the unit's name (:attr:`_name`). 

1391 

1392 :returns: The name. 

1393 """ 

1394 return self._name 

1395 

1396 @readonly 

1397 def QualifiedName(self) -> str: 

1398 """ 

1399 Read-only property to return the names of the units from the top down to this one, joined by ``.``. 

1400 

1401 A method ``Area`` of a class ``Circle`` in a module ``myPackage.Shapes`` has the qualified name 

1402 ``myPackage.Shapes.Circle.Area``. 

1403 

1404 :returns: The qualified name. 

1405 """ 

1406 if isinstance(self._parent, Unit): 

1407 return f"{self._parent.QualifiedName}.{self._name}" 

1408 

1409 return self._name 

1410 

1411 @readonly 

1412 def Units(self) -> dict[str, Unit]: 

1413 """ 

1414 Read-only property to access the units this one contains (:attr:`_units`). 

1415 

1416 :returns: The units, by name. 

1417 """ 

1418 return self._units 

1419 

1420 @readonly 

1421 def File(self) -> Nullable[File]: 

1422 """ 

1423 Read-only property to access the source file the unit is in (:attr:`_file`). 

1424 

1425 :returns: The file, or ``None`` if the report doesn't say. 

1426 """ 

1427 return self._file 

1428 

1429 @readonly 

1430 def StartLine(self) -> Nullable[Line]: 

1431 """ 

1432 Read-only property to access the unit's first line (:attr:`_startLine`). 

1433 

1434 :returns: The line of the unit's file, or ``None`` if the report doesn't say. 

1435 """ 

1436 return self._startLine 

1437 

1438 @readonly 

1439 def EndLine(self) -> Nullable[Line]: 

1440 """ 

1441 Read-only property to access the unit's last line (:attr:`_endLine`). 

1442 

1443 :returns: The line of the unit's file, or ``None`` if the report doesn't say. 

1444 """ 

1445 return self._endLine 

1446 

1447 def IterateLines(self) -> Generator[Line, None, None]: 

1448 """ 

1449 Iterate the executable lines of this unit's file from its first to its last line, both included. 

1450 

1451 :returns: A generator of the lines; nothing, if the unit has no file, or no first or last line. 

1452 """ 

1453 if self._file is not None and self._startLine is not None and self._endLine is not None: 

1454 yield from self._file.IterateLines(self._startLine, self._endLine) 

1455 

1456 def IterateUnits(self, predicate: Nullable[Callable[[Unit], bool]] = None) -> Generator[Unit, None, None]: 

1457 """ 

1458 Iterate this unit and the units it contains, all or selected ones. 

1459 

1460 This unit comes first, then the units it contains, each level in the order the units were added. If parameter 

1461 ``predicate`` is not None, the given filter function is used to skip units in the generator. A skipped unit's units 

1462 are still iterated. 

1463 

1464 :param predicate: Optional, filter function accepting any unit and returning a boolean. Default: ``None``. 

1465 :returns: A generator of the units. 

1466 """ 

1467 if predicate is None: 

1468 yield self 

1469 for unit in self._units.values(): 

1470 yield from unit.IterateUnits() 

1471 else: 

1472 if predicate(self): 

1473 yield self 

1474 

1475 for unit in self._units.values(): 

1476 yield from unit.IterateUnits(predicate) 

1477 

1478 def Aggregate(self) -> None: 

1479 """ 

1480 Aggregate the units this one contains, then compute the counters from the lines this unit spans. 

1481 

1482 A unit without first and last line counts the lines the units below it span, each line once. The lines are 

1483 aggregated by their files - :meth:`CoverageSummary.Aggregate` does that first. 

1484 """ 

1485 for unit in self._units.values(): 

1486 unit.Aggregate() 

1487 

1488 if self._startLine is not None and self._endLine is not None: 

1489 self._CountLines(self.IterateLines()) 

1490 else: 

1491 lines: dict[int, Line] = {} 

1492 for unit in self.IterateUnits(): 

1493 lines.update((id(line), line) for line in unit.IterateLines()) 

1494 

1495 self._CountLines(lines.values()) 

1496 

1497 def __repr__(self) -> str: 

1498 """ 

1499 Return a representation of the unit for debugging. 

1500 

1501 A method ``Area`` of class ``Circle`` in module ``Shapes`` reads ``<Function Shapes.Circle.Area: 100.0%>``. 

1502 

1503 :returns: The unit's kind, qualified name and line coverage. 

1504 """ 

1505 return f"<{getTypeName(self)} {self.QualifiedName}: {self.LineCoverage:.1%}>" 

1506 

1507 

1508Unit._PARENT_TYPE = (Unit, CoverageSummary) 

1509 

1510 

1511@export 

1512class Package(Unit): 

1513 """ 

1514 A package: a unit grouping modules. 

1515 

1516 Python and Java have packages, VHDL has libraries. 

1517 """ 

1518 

1519 

1520@export 

1521class Module(Unit): 

1522 """ 

1523 A module: a unit of a package. 

1524 

1525 A Python module is one, and so are a VHDL package and a VHDL entity. 

1526 """ 

1527 

1528 

1529@export 

1530class SourceFile(Unit): 

1531 """ 

1532 A source file as a unit, where the file is the language's unit. 

1533 

1534 A C translation unit is one, and so is a Bash or TCL script. 

1535 """ 

1536 

1537 

1538@export 

1539class Class(Unit): 

1540 """ 

1541 A class. 

1542 

1543 Python, Java and C++ have classes. 

1544 """ 

1545 

1546 

1547@export 

1548class Function(Unit): 

1549 """ 

1550 A function. 

1551 

1552 Python and C have functions, VHDL has functions and procedures. 

1553 """ 

1554 

1555 

1556@export 

1557class Method(Unit): 

1558 """A method of a class.""" 

1559 

1560 

1561@export 

1562@abstractclass 

1563class Document(metaclass=ExtendedType, slots=True): 

1564 """ 

1565 A code coverage report file, which is read in two steps: analyzed, then converted. 

1566 

1567 A format's document derives from this class and mixes in the root of the format's model. 

1568 """ 

1569 

1570 _path: Path #: Path to the report file. 

1571 _analysisDuration: Nullable[timedelta] #: Duration of :meth:`Analyze`, or ``None`` before it ran. 

1572 _conversionDuration: Nullable[timedelta] #: Duration of :meth:`Convert`, or ``None`` before it ran. 

1573 

1574 def __init__(self, reportFile: Path) -> None: 

1575 """ 

1576 Initialize the report file. 

1577 

1578 :param reportFile: Path to the report file. 

1579 """ 

1580 self._path = reportFile 

1581 

1582 self._analysisDuration = None 

1583 self._conversionDuration = None 

1584 

1585 @readonly 

1586 def ReportFile(self) -> Path: 

1587 """ 

1588 Read-only property to access the path to the report file (:attr:`_path`). 

1589 

1590 :returns: The report file's path. 

1591 """ 

1592 return self._path 

1593 

1594 @readonly 

1595 def AnalysisDuration(self) -> Nullable[timedelta]: 

1596 """ 

1597 Read-only property to access the duration of :meth:`Analyze`: reading, parsing and validating the file 

1598 (:attr:`_analysisDuration`). 

1599 

1600 :returns: The duration, or ``None`` if the file wasn't analyzed yet. 

1601 """ 

1602 return self._analysisDuration 

1603 

1604 @readonly 

1605 def ModelConversionDuration(self) -> Nullable[timedelta]: 

1606 """ 

1607 Read-only property to access the duration of :meth:`Convert`: building the report format's model 

1608 (:attr:`_conversionDuration`). 

1609 

1610 :returns: The duration, or ``None`` if the content wasn't converted yet. 

1611 """ 

1612 return self._conversionDuration 

1613 

1614 @abstractmethod 

1615 def Analyze(self) -> None: 

1616 """ 

1617 Read, parse and validate the report file. 

1618 """ 

1619 

1620 @abstractmethod 

1621 def Convert(self) -> None: 

1622 """ 

1623 Convert the analyzed content to the report format's model. 

1624 """ 

1625 

1626 @abstractmethod 

1627 def ToCoverageSummary(self) -> CoverageSummary: 

1628 """ 

1629 Convert the report format's model to the common code coverage model, and aggregate it. 

1630 

1631 :returns: The report's root of the common model. 

1632 """