Coverage for pyEDAA/Reports/DocumentationCoverage/Python.py: 76%

286 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-07 22:48 +0000

1# ==================================================================================================================== # 

2# _____ ____ _ _ ____ _ # 

3# _ __ _ _| ____| _ \ / \ / \ | _ \ ___ _ __ ___ _ __| |_ ___ # 

4# | '_ \| | | | _| | | | |/ _ \ / _ \ | |_) / _ \ '_ \ / _ \| '__| __/ __| # 

5# | |_) | |_| | |___| |_| / ___ \ / ___ \ _| _ < __/ |_) | (_) | | | |_\__ \ # 

6# | .__/ \__, |_____|____/_/ \_\/_/ \_(_)_| \_\___| .__/ \___/|_| \__|___/ # 

7# |_| |___/ |_| # 

8# ==================================================================================================================== # 

9# Authors: # 

10# Patrick Lehmann # 

11# # 

12# License: # 

13# ==================================================================================================================== # 

14# Copyright 2024-2026 Electronic Design Automation Abstraction (EDA²) # 

15# Copyright 2023-2023 Patrick Lehmann - Bötzingen, Germany # 

16# # 

17# Licensed under the Apache License, Version 2.0 (the "License"); # 

18# you may not use this file except in compliance with the License. # 

19# You may obtain a copy of the License at # 

20# # 

21# http://www.apache.org/licenses/LICENSE-2.0 # 

22# # 

23# Unless required by applicable law or agreed to in writing, software # 

24# distributed under the License is distributed on an "AS IS" BASIS, # 

25# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # 

26# See the License for the specific language governing permissions and # 

27# limitations under the License. # 

28# # 

29# SPDX-License-Identifier: Apache-2.0 # 

30# ==================================================================================================================== # 

31# 

32""" 

33**Abstract code documentation coverage data model for Python code.** 

34""" 

35from pathlib import Path 

36from typing import Optional as Nullable, Iterable, Dict, Union, Tuple, List 

37 

38from docstr_coverage import analyze, ResultCollection 

39from docstr_coverage.result_collection import FileCount 

40 

41from pyTooling.Decorators import export, readonly 

42from pyTooling.MetaClasses import ExtendedType 

43 

44from pyEDAA.Reports.DocumentationCoverage import Class, Module, Package, CoverageState, DocCoverageException 

45 

46 

47@export 

48class Coverage(metaclass=ExtendedType, mixin=True): 

49 """ 

50 This base-class for :class:`ClassCoverage` and :class:`AggregatedCoverage` represents a basic set of documentation coverage metrics. 

51 

52 Besides the *total* number of coverable items, it distinguishes items as *excluded*, *ignored*, and *expected*. |br| 

53 Expected items are further distinguished into *covered* and *uncovered* items. |br| 

54 If no item is expected, then *coverage* is always 100 |%|. 

55 

56 All coverable items 

57 total = excluded + ignored + expected 

58 

59 All expected items 

60 expected = covered + uncovered 

61 

62 Coverage [0.00..1.00] 

63 coverage = covered / expected 

64 """ 

65 _total: int 

66 _excluded: int 

67 _ignored: int 

68 _expected: int 

69 _covered: int 

70 _uncovered: int 

71 

72 _coverage: float 

73 

74 def __init__(self) -> None: 

75 self._total = 0 

76 self._excluded = 0 

77 self._ignored = 0 

78 self._expected = 0 

79 self._covered = 0 

80 self._uncovered = 0 

81 

82 self._coverage = -1.0 

83 

84 @readonly 

85 def Total(self) -> int: 

86 """ 

87 Read-only property to access the total number of documentable items. 

88 

89 :returns: Total number of items. 

90 """ 

91 return self._total 

92 

93 @readonly 

94 def Excluded(self) -> int: 

95 """ 

96 Read-only property to access the number of items excluded from the analysis. 

97 

98 :returns: Number of excluded items. 

99 """ 

100 return self._excluded 

101 

102 @readonly 

103 def Ignored(self) -> int: 

104 """ 

105 Read-only property to access the number of items ignored by the analysis. 

106 

107 :returns: Number of ignored items. 

108 """ 

109 return self._ignored 

110 

111 @readonly 

112 def Expected(self) -> int: 

113 """ 

114 Read-only property to access the number of items expected to be documented. 

115 

116 :returns: Number of expected items. 

117 """ 

118 return self._expected 

119 

120 @readonly 

121 def Covered(self) -> int: 

122 """ 

123 Read-only property to access the number of documented items. 

124 

125 :returns: Number of covered items. 

126 """ 

127 return self._covered 

128 

129 @readonly 

130 def Uncovered(self) -> int: 

131 """ 

132 Read-only property to access the number of undocumented items. 

133 

134 :returns: Number of uncovered items. 

135 """ 

136 return self._uncovered 

137 

138 @readonly 

139 def Coverage(self) -> float: 

140 """ 

141 Read-only property to access the ratio of covered to expected items. 

142 

143 :returns: Documentation coverage in the range 0.0 to 1.0. 

144 """ 

145 return self._coverage 

146 

147 def CalculateCoverage(self) -> None: 

148 self._uncovered = self._expected - self._covered 

149 if self._expected != 0: 

150 self._coverage = self._covered / self._expected 

151 else: 

152 self._coverage = 1.0 

153 

154 def _CountCoverage(self, iterator: Iterable[CoverageState]) -> Tuple[int, int, int, int, int]: 

155 total = 0 

156 excluded = 0 

157 ignored = 0 

158 expected = 0 

159 covered = 0 

160 for coverageState in iterator: 

161 if coverageState is CoverageState.Unknown: 

162 raise Exception(f"") 

163 

164 total += 1 

165 

166 if CoverageState.Excluded in coverageState: 

167 excluded += 1 

168 elif CoverageState.Ignored in coverageState: 

169 ignored += 1 

170 

171 expected += 1 

172 if CoverageState.Covered in coverageState: 

173 covered += 1 

174 

175 return total, excluded, ignored, expected, covered 

176 

177 

178@export 

179class AggregatedCoverage(Coverage, mixin=True): 

180 """ 

181 This base-class for :class:`ModuleCoverage` and :class:`PackageCoverage` represents an extended set of documentation coverage metrics, especially with aggregated metrics. 

182 

183 As inherited from :class:`~Coverage`, it provides the *total* number of coverable items, which are distinguished into 

184 *excluded*, *ignored*, and *expected* items. |br| 

185 Expected items are further distinguished into *covered* and *uncovered* items. |br| 

186 If no item is expected, then *coverage* and *aggregated coverage* are always 100 |%|. 

187 

188 In addition, all previously mentioned metrics are collected as *aggregated...*, too. |br| 

189 

190 All coverable items 

191 total = excluded + ignored + expected 

192 

193 All expected items 

194 expected = covered + uncovered 

195 

196 Coverage [0.00..1.00] 

197 coverage = covered / expected 

198 """ 

199 _file: Path 

200 

201 _aggregatedTotal: int 

202 _aggregatedExcluded: int 

203 _aggregatedIgnored: int 

204 _aggregatedExpected: int 

205 _aggregatedCovered: int 

206 _aggregatedUncovered: int 

207 

208 _aggregatedCoverage: float 

209 

210 def __init__(self, file: Path) -> None: 

211 super().__init__() 

212 self._file = file 

213 

214 @readonly 

215 def File(self) -> Path: 

216 """ 

217 Read-only property to access the file this coverage was computed from. 

218 

219 :returns: Path to the analyzed file. 

220 """ 

221 return self._file 

222 

223 @readonly 

224 def AggregatedTotal(self) -> int: 

225 """ 

226 Read-only property to access the total number of documentable items, including all children. 

227 

228 :returns: Aggregated total number of items. 

229 """ 

230 return self._aggregatedTotal 

231 

232 @readonly 

233 def AggregatedExcluded(self) -> int: 

234 """ 

235 Read-only property to access the number of excluded items, including all children. 

236 

237 :returns: Aggregated number of excluded items. 

238 """ 

239 return self._aggregatedExcluded 

240 

241 @readonly 

242 def AggregatedIgnored(self) -> int: 

243 """ 

244 Read-only property to access the number of ignored items, including all children. 

245 

246 :returns: Aggregated number of ignored items. 

247 """ 

248 return self._aggregatedIgnored 

249 

250 @readonly 

251 def AggregatedExpected(self) -> int: 

252 """ 

253 Read-only property to access the number of expected items, including all children. 

254 

255 :returns: Aggregated number of expected items. 

256 """ 

257 return self._aggregatedExpected 

258 

259 @readonly 

260 def AggregatedCovered(self) -> int: 

261 """ 

262 Read-only property to access the number of documented items, including all children. 

263 

264 :returns: Aggregated number of covered items. 

265 """ 

266 return self._aggregatedCovered 

267 

268 @readonly 

269 def AggregatedUncovered(self) -> int: 

270 """ 

271 Read-only property to access the number of undocumented items, including all children. 

272 

273 :returns: Aggregated number of uncovered items. 

274 """ 

275 return self._aggregatedUncovered 

276 

277 @readonly 

278 def AggregatedCoverage(self) -> float: 

279 """ 

280 Read-only property to access the coverage ratio, including all children. 

281 

282 :returns: Aggregated documentation coverage in the range 0.0 to 1.0. 

283 """ 

284 return self._aggregatedCoverage 

285 

286 def Aggregate(self) -> None: 

287 if self._aggregatedExpected != 0: 

288 self._aggregatedCoverage = self._aggregatedCovered / self._aggregatedExpected 

289 else: 

290 self._aggregatedCoverage = 1.0 

291 

292 

293@export 

294class ClassCoverage(Class, Coverage): 

295 """ 

296 This class represents the class documentation coverage for Python classes. 

297 """ 

298 _fields: Dict[str, CoverageState] 

299 _methods: Dict[str, CoverageState] 

300 _classes: Dict[str, "ClassCoverage"] 

301 

302 def __init__(self, name: str, parent: Union["PackageCoverage", "ClassCoverage", None] = None) -> None: 

303 super().__init__(name, parent) 

304 Coverage.__init__(self) 

305 

306 if parent is not None: 

307 parent._classes[name] = self 

308 

309 self._fields = {} 

310 self._methods = {} 

311 self._classes = {} 

312 

313 @readonly 

314 def Fields(self) -> Dict[str, CoverageState]: 

315 """ 

316 Read-only property to access the coverage states of the class' fields. 

317 

318 :returns: Dictionary of field names and their coverage states. 

319 """ 

320 return self._fields 

321 

322 @readonly 

323 def Methods(self) -> Dict[str, CoverageState]: 

324 """ 

325 Read-only property to access the coverage states of the class' methods. 

326 

327 :returns: Dictionary of method names and their coverage states. 

328 """ 

329 return self._methods 

330 

331 @readonly 

332 def Classes(self) -> Dict[str, "ClassCoverage"]: 

333 """ 

334 Read-only property to access the class' nested classes. 

335 

336 :returns: Dictionary of class names and their coverage. 

337 """ 

338 return self._classes 

339 

340 def CalculateCoverage(self) -> None: 

341 for cls in self._classes.values(): 

342 cls.CalculateCoverage() 

343 

344 self._total, self._excluded, self._ignored, self._expected, self._covered = \ 

345 self._CountCoverage(zip( 

346 self._fields.values(), 

347 self._methods.values() 

348 )) 

349 

350 super().CalculateCoverage() 

351 

352 def __str__(self) -> str: 

353 return f"<ClassCoverage - tot:{self._total}, ex:{self._excluded}, ig:{self._ignored}, exp:{self._expected}, cov:{self._covered}, un:{self._uncovered} => {self._coverage:.1%}>" 

354 

355 

356@export 

357class ModuleCoverage(Module, AggregatedCoverage): 

358 """ 

359 This class represents the module documentation coverage for Python modules. 

360 """ 

361 _variables: Dict[str, CoverageState] 

362 _functions: Dict[str, CoverageState] 

363 _classes: Dict[str, ClassCoverage] 

364 

365 def __init__(self, name: str, file: Path, parent: Nullable["PackageCoverage"] = None) -> None: 

366 super().__init__(name, parent) 

367 AggregatedCoverage.__init__(self, file) 

368 

369 if parent is not None: 

370 parent._modules[name] = self 

371 

372 self._file = file 

373 self._variables = {} 

374 self._functions = {} 

375 self._classes = {} 

376 

377 @readonly 

378 def Variables(self) -> Dict[str, CoverageState]: 

379 """ 

380 Read-only property to access the coverage states of the module's variables. 

381 

382 :returns: Dictionary of variable names and their coverage states. 

383 """ 

384 return self._variables 

385 

386 @readonly 

387 def Functions(self) -> Dict[str, CoverageState]: 

388 """ 

389 Read-only property to access the coverage states of the module's functions. 

390 

391 :returns: Dictionary of function names and their coverage states. 

392 """ 

393 return self._functions 

394 

395 @readonly 

396 def Classes(self) -> Dict[str, ClassCoverage]: 

397 """ 

398 Read-only property to access the module's classes. 

399 

400 :returns: Dictionary of class names and their coverage. 

401 """ 

402 return self._classes 

403 

404 def CalculateCoverage(self) -> None: 

405 for cls in self._classes.values(): 

406 cls.CalculateCoverage() 

407 

408 self._total, self._excluded, self._ignored, self._expected, self._covered = \ 

409 self._CountCoverage(zip( 

410 self._variables.values(), 

411 self._functions.values() 

412 )) 

413 

414 super().CalculateCoverage() 

415 

416 def Aggregate(self) -> None: 

417 self._aggregatedTotal = self._total 

418 self._aggregatedExcluded = self._excluded 

419 self._aggregatedIgnored = self._ignored 

420 self._aggregatedExpected = self._expected 

421 self._aggregatedCovered = self._covered 

422 self._aggregatedUncovered = self._uncovered 

423 

424 for cls in self._classes.values(): 

425 self._aggregatedTotal += cls._total 

426 self._aggregatedExcluded += cls._excluded 

427 self._aggregatedIgnored += cls._ignored 

428 self._aggregatedExpected += cls._expected 

429 self._aggregatedCovered += cls._covered 

430 self._aggregatedUncovered += cls._uncovered 

431 

432 super().Aggregate() 

433 

434 def __str__(self) -> str: 

435 return f"<ModuleCoverage - tot:{self._total}|{self._aggregatedTotal}, ex:{self._excluded}|{self._aggregatedExcluded}, ig:{self._ignored}|{self._aggregatedIgnored}, exp:{self._expected}|{self._aggregatedExpected}, cov:{self._covered}|{self._aggregatedCovered}, un:{self._uncovered}|{self._aggregatedUncovered} => {self._coverage:.1%}|{self._aggregatedCoverage:.1%}>" 

436 

437 

438@export 

439class PackageCoverage(Package, AggregatedCoverage): 

440 """ 

441 This class represents the package documentation coverage for Python packages. 

442 """ 

443 _fileCount: int 

444 _variables: Dict[str, CoverageState] 

445 _functions: Dict[str, CoverageState] 

446 _classes: Dict[str, ClassCoverage] 

447 _modules: Dict[str, ModuleCoverage] 

448 _packages: Dict[str, "PackageCoverage"] 

449 

450 def __init__(self, name: str, file: Path, parent: Nullable["PackageCoverage"] = None) -> None: 

451 super().__init__(name, parent) 

452 AggregatedCoverage.__init__(self, file) 

453 

454 if parent is not None: 

455 parent._packages[name] = self 

456 

457 self._file = file 

458 self._fileCount = 1 

459 self._variables = {} 

460 self._functions = {} 

461 self._classes = {} 

462 self._modules = {} 

463 self._packages = {} 

464 

465 @readonly 

466 def FileCount(self) -> int: 

467 """ 

468 Read-only property to access the number of Python files in this package. 

469 

470 :returns: Number of files. 

471 """ 

472 return self._fileCount 

473 

474 @readonly 

475 def Variables(self) -> Dict[str, CoverageState]: 

476 """ 

477 Read-only property to access the coverage states of the package's variables. 

478 

479 :returns: Dictionary of variable names and their coverage states. 

480 """ 

481 return self._variables 

482 

483 @readonly 

484 def Functions(self) -> Dict[str, CoverageState]: 

485 """ 

486 Read-only property to access the coverage states of the package's functions. 

487 

488 :returns: Dictionary of function names and their coverage states. 

489 """ 

490 return self._functions 

491 

492 @readonly 

493 def Classes(self) -> Dict[str, ClassCoverage]: 

494 """ 

495 Read-only property to access the package's classes. 

496 

497 :returns: Dictionary of class names and their coverage. 

498 """ 

499 return self._classes 

500 

501 @readonly 

502 def Modules(self) -> Dict[str, ModuleCoverage]: 

503 """ 

504 Read-only property to access the package's modules. 

505 

506 :returns: Dictionary of module names and their coverage. 

507 """ 

508 return self._modules 

509 

510 @readonly 

511 def Packages(self) -> Dict[str, "PackageCoverage"]: 

512 """ 

513 Read-only property to access the package's sub-packages. 

514 

515 :returns: Dictionary of package names and their coverage. 

516 """ 

517 return self._packages 

518 

519 def __getitem__(self, key: str) -> Union["PackageCoverage", ModuleCoverage]: 

520 try: 

521 return self._modules[key] 

522 except KeyError: 

523 return self._packages[key] 

524 

525 def CalculateCoverage(self) -> None: 

526 for cls in self._classes.values(): 

527 cls.CalculateCoverage() 

528 

529 for mod in self._modules.values(): 

530 mod.CalculateCoverage() 

531 

532 for pkg in self._packages.values(): 

533 pkg.CalculateCoverage() 

534 

535 self._total, self._excluded, self._ignored, self._expected, self._covered = \ 

536 self._CountCoverage(zip( 

537 self._variables.values(), 

538 self._functions.values() 

539 )) 

540 

541 super().CalculateCoverage() 

542 

543 def Aggregate(self) -> None: 

544 self._fileCount = len(self._modules) + 1 

545 self._aggregatedTotal = self._total 

546 self._aggregatedExcluded = self._excluded 

547 self._aggregatedIgnored = self._ignored 

548 self._aggregatedExpected = self._expected 

549 self._aggregatedCovered = self._covered 

550 self._aggregatedUncovered = self._uncovered 

551 

552 for pkg in self._packages.values(): 

553 pkg.Aggregate() 

554 self._fileCount += pkg._fileCount 

555 self._aggregatedTotal += pkg._total 

556 self._aggregatedExcluded += pkg._excluded 

557 self._aggregatedIgnored += pkg._ignored 

558 self._aggregatedExpected += pkg._expected 

559 self._aggregatedCovered += pkg._covered 

560 self._aggregatedUncovered += pkg._uncovered 

561 

562 for mod in self._modules.values(): 

563 mod.Aggregate() 

564 self._aggregatedTotal += mod._total 

565 self._aggregatedExcluded += mod._excluded 

566 self._aggregatedIgnored += mod._ignored 

567 self._aggregatedExpected += mod._expected 

568 self._aggregatedCovered += mod._covered 

569 self._aggregatedUncovered += mod._uncovered 

570 

571 super().Aggregate() 

572 

573 def __str__(self) -> str: 

574 return f"<PackageCoverage - tot:{self._total}|{self._aggregatedTotal}, ex:{self._excluded}|{self._aggregatedExcluded}, ig:{self._ignored}|{self._aggregatedIgnored}, exp:{self._expected}|{self._aggregatedExpected}, cov:{self._covered}|{self._aggregatedCovered}, un:{self._uncovered}|{self._aggregatedUncovered} => {self._coverage:.1%}|{self._aggregatedCoverage:.1%}>" 

575 

576 

577@export 

578class DocStrCoverageError(DocCoverageException): 

579 pass 

580 

581 

582@export 

583class DocStrCoverage(metaclass=ExtendedType): 

584 """ 

585 A wrapper class for the docstr_coverage package and it's analyzer producing a documentation coverage model. 

586 """ 

587 _packageName: str 

588 _searchDirectory: Path 

589 _moduleFiles: List[Path] 

590 _coverageReport: ResultCollection 

591 

592 def __init__(self, packageName: str, directory: Path) -> None: 

593 if not directory.exists(): 593 ↛ 594line 593 didn't jump to line 594 because the condition on line 593 was never true

594 raise DocStrCoverageError(f"Package source directory '{directory}' does not exist.") from FileNotFoundError(f"Directory '{directory}' does not exist.") 

595 

596 self._searchDirectory = directory 

597 self._packageName = packageName 

598 self._moduleFiles = [file for file in directory.glob("**/*.py")] 

599 

600 @readonly 

601 def SearchDirectories(self) -> Path: 

602 """ 

603 Read-only property to access the directory the analysis searches for Python files. 

604 

605 :returns: Path to the search directory. 

606 """ 

607 return self._searchDirectory 

608 

609 @readonly 

610 def PackageName(self) -> str: 

611 """ 

612 Read-only property to access the name of the analyzed package. 

613 

614 :returns: Name of the package. 

615 """ 

616 return self._packageName 

617 

618 @readonly 

619 def ModuleFiles(self) -> List[Path]: 

620 """ 

621 Read-only property to access the Python files found in the search directory. 

622 

623 :returns: List of module file paths. 

624 """ 

625 return self._moduleFiles 

626 

627 @readonly 

628 def CoverageReport(self) -> ResultCollection: 

629 """ 

630 Read-only property to access the raw report produced by ``docstr_coverage``. 

631 

632 :returns: The analyzer's result collection. 

633 """ 

634 return self._coverageReport 

635 

636 def Analyze(self) -> ResultCollection: 

637 self._coverageReport: ResultCollection = analyze(self._moduleFiles, show_progress=False) 

638 return self._coverageReport 

639 

640 def Convert(self) -> PackageCoverage: 

641 rootPackageCoverage = PackageCoverage(self._packageName, self._searchDirectory / "__init__.py") 

642 

643 for key, value in self._coverageReport.files(): 

644 path: Path = key.relative_to(self._searchDirectory) 

645 perFileResult: FileCount = value.count_aggregate() 

646 

647 moduleName = path.stem 

648 modulePath = path.parent.parts 

649 

650 currentCoverageObject: AggregatedCoverage = rootPackageCoverage 

651 for packageName in modulePath: 

652 try: 

653 currentCoverageObject = currentCoverageObject[packageName] 

654 except KeyError: 

655 currentCoverageObject = PackageCoverage(packageName, path, currentCoverageObject) 

656 

657 if moduleName != "__init__": 

658 currentCoverageObject = ModuleCoverage(moduleName, path, currentCoverageObject) 

659 

660 currentCoverageObject._expected = perFileResult.needed 

661 currentCoverageObject._covered = perFileResult.found 

662 currentCoverageObject._uncovered = perFileResult.missing 

663 

664 if currentCoverageObject._expected != 0: 

665 currentCoverageObject._coverage = currentCoverageObject._covered / currentCoverageObject._expected 

666 else: 

667 currentCoverageObject._coverage = 1.0 

668 

669 if currentCoverageObject._uncovered != currentCoverageObject._expected - currentCoverageObject._covered: 669 ↛ 670line 669 didn't jump to line 670 because the condition on line 669 was never true

670 currentCoverageObject._coverage = -2.0 

671 

672 return rootPackageCoverage 

673