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

319 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2025-11-21 22:27 +0000

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

2# _____ ____ _ _ ____ _ # 

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

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

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

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

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

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

9# Authors: # 

10# Patrick Lehmann # 

11# # 

12# License: # 

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

14# Copyright 2024-2025 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 pyTooling.Decorators import export, readonly 

39from pyTooling.MetaClasses import ExtendedType 

40 

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

42 

43 

44@export 

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

46 """ 

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

48 

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

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

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

52 

53 All coverable items 

54 total = excluded + ignored + expected 

55 

56 All expected items 

57 expected = covered + uncovered 

58 

59 Coverage [0.00..1.00] 

60 coverage = covered / expected 

61 """ 

62 _total: int 

63 _excluded: int 

64 _ignored: int 

65 _expected: int 

66 _covered: int 

67 _uncovered: int 

68 

69 _coverage: float 

70 

71 def __init__(self) -> None: 

72 self._total = 0 

73 self._excluded = 0 

74 self._ignored = 0 

75 self._expected = 0 

76 self._covered = 0 

77 self._uncovered = 0 

78 

79 self._coverage = -1.0 

80 

81 @readonly 

82 def Total(self) -> int: 

83 return self._total 

84 

85 @readonly 

86 def Excluded(self) -> int: 

87 return self._excluded 

88 

89 @readonly 

90 def Ignored(self) -> int: 

91 return self._ignored 

92 

93 @readonly 

94 def Expected(self) -> int: 

95 return self._expected 

96 

97 @readonly 

98 def Covered(self) -> int: 

99 return self._covered 

100 

101 @readonly 

102 def Uncovered(self) -> int: 

103 return self._uncovered 

104 

105 @readonly 

106 def Coverage(self) -> float: 

107 return self._coverage 

108 

109 def CalculateCoverage(self) -> None: 

110 self._uncovered = self._expected - self._covered 

111 if self._expected != 0: 

112 self._coverage = self._covered / self._expected 

113 else: 

114 self._coverage = 1.0 

115 

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

117 total = 0 

118 excluded = 0 

119 ignored = 0 

120 expected = 0 

121 covered = 0 

122 for coverageState in iterator: 

123 if coverageState is CoverageState.Unknown: 

124 raise Exception(f"") 

125 

126 total += 1 

127 

128 if CoverageState.Excluded in coverageState: 

129 excluded += 1 

130 elif CoverageState.Ignored in coverageState: 

131 ignored += 1 

132 

133 expected += 1 

134 if CoverageState.Covered in coverageState: 

135 covered += 1 

136 

137 return total, excluded, ignored, expected, covered 

138 

139 

140@export 

141class AggregatedCoverage(Coverage, mixin=True): 

142 """ 

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

144 

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

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

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

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

149 

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

151 

152 All coverable items 

153 total = excluded + ignored + expected 

154 

155 All expected items 

156 expected = covered + uncovered 

157 

158 Coverage [0.00..1.00] 

159 coverage = covered / expected 

160 """ 

161 _file: Path 

162 

163 _aggregatedTotal: int 

164 _aggregatedExcluded: int 

165 _aggregatedIgnored: int 

166 _aggregatedExpected: int 

167 _aggregatedCovered: int 

168 _aggregatedUncovered: int 

169 

170 _aggregatedCoverage: float 

171 

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

173 super().__init__() 

174 self._file = file 

175 

176 @readonly 

177 def File(self) -> Path: 

178 return self._file 

179 

180 @readonly 

181 def AggregatedTotal(self) -> int: 

182 return self._aggregatedTotal 

183 

184 @readonly 

185 def AggregatedExcluded(self) -> int: 

186 return self._aggregatedExcluded 

187 

188 @readonly 

189 def AggregatedIgnored(self) -> int: 

190 return self._aggregatedIgnored 

191 

192 @readonly 

193 def AggregatedExpected(self) -> int: 

194 return self._aggregatedExpected 

195 

196 @readonly 

197 def AggregatedCovered(self) -> int: 

198 return self._aggregatedCovered 

199 

200 @readonly 

201 def AggregatedUncovered(self) -> int: 

202 return self._aggregatedUncovered 

203 

204 @readonly 

205 def AggregatedCoverage(self) -> float: 

206 return self._aggregatedCoverage 

207 

208 def Aggregate(self) -> None: 

209 if self._aggregatedExpected != 0: 

210 self._aggregatedCoverage = self._aggregatedCovered / self._aggregatedExpected 

211 else: 

212 self._aggregatedCoverage = 1.0 

213 

214 

215@export 

216class ClassCoverage(Class, Coverage): 

217 """ 

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

219 """ 

220 _fields: Dict[str, CoverageState] 

221 _methods: Dict[str, CoverageState] 

222 _classes: Dict[str, "ClassCoverage"] 

223 

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

225 super().__init__(name, parent) 

226 Coverage.__init__(self) 

227 

228 if parent is not None: 

229 parent._classes[name] = self 

230 

231 self._fields = {} 

232 self._methods = {} 

233 self._classes = {} 

234 

235 @readonly 

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

237 return self._fields 

238 

239 @readonly 

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

241 return self._methods 

242 

243 @readonly 

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

245 return self._classes 

246 

247 def CalculateCoverage(self) -> None: 

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

249 cls.CalculateCoverage() 

250 

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

252 self._CountCoverage(zip( 

253 self._fields.values(), 

254 self._methods.values() 

255 )) 

256 

257 super().CalculateCoverage() 

258 

259 def __str__(self) -> str: 

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

261 

262 

263@export 

264class ModuleCoverage(Module, AggregatedCoverage): 

265 """ 

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

267 """ 

268 _variables: Dict[str, CoverageState] 

269 _functions: Dict[str, CoverageState] 

270 _classes: Dict[str, ClassCoverage] 

271 

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

273 super().__init__(name, parent) 

274 AggregatedCoverage.__init__(self, file) 

275 

276 if parent is not None: 

277 parent._modules[name] = self 

278 

279 self._file = file 

280 self._variables = {} 

281 self._functions = {} 

282 self._classes = {} 

283 

284 @readonly 

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

286 return self._variables 

287 

288 @readonly 

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

290 return self._functions 

291 

292 @readonly 

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

294 return self._classes 

295 

296 def CalculateCoverage(self) -> None: 

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

298 cls.CalculateCoverage() 

299 

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

301 self._CountCoverage(zip( 

302 self._variables.values(), 

303 self._functions.values() 

304 )) 

305 

306 super().CalculateCoverage() 

307 

308 def Aggregate(self) -> None: 

309 self._aggregatedTotal = self._total 

310 self._aggregatedExcluded = self._excluded 

311 self._aggregatedIgnored = self._ignored 

312 self._aggregatedExpected = self._expected 

313 self._aggregatedCovered = self._covered 

314 self._aggregatedUncovered = self._uncovered 

315 

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

317 self._aggregatedTotal += cls._total 

318 self._aggregatedExcluded += cls._excluded 

319 self._aggregatedIgnored += cls._ignored 

320 self._aggregatedExpected += cls._expected 

321 self._aggregatedCovered += cls._covered 

322 self._aggregatedUncovered += cls._uncovered 

323 

324 super().Aggregate() 

325 

326 def __str__(self) -> str: 

327 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%}>" 

328 

329 

330@export 

331class PackageCoverage(Package, AggregatedCoverage): 

332 """ 

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

334 """ 

335 _fileCount: int 

336 _variables: Dict[str, CoverageState] 

337 _functions: Dict[str, CoverageState] 

338 _classes: Dict[str, ClassCoverage] 

339 _modules: Dict[str, ModuleCoverage] 

340 _packages: Dict[str, "PackageCoverage"] 

341 

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

343 super().__init__(name, parent) 

344 AggregatedCoverage.__init__(self, file) 

345 

346 if parent is not None: 

347 parent._packages[name] = self 

348 

349 self._file = file 

350 self._fileCount = 1 

351 self._variables = {} 

352 self._functions = {} 

353 self._classes = {} 

354 self._modules = {} 

355 self._packages = {} 

356 

357 @readonly 

358 def FileCount(self) -> int: 

359 return self._fileCount 

360 

361 @readonly 

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

363 return self._variables 

364 

365 @readonly 

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

367 return self._functions 

368 

369 @readonly 

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

371 return self._classes 

372 

373 @readonly 

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

375 return self._modules 

376 

377 @readonly 

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

379 return self._packages 

380 

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

382 try: 

383 return self._modules[key] 

384 except KeyError: 

385 return self._packages[key] 

386 

387 def CalculateCoverage(self) -> None: 

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

389 cls.CalculateCoverage() 

390 

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

392 mod.CalculateCoverage() 

393 

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

395 pkg.CalculateCoverage() 

396 

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

398 self._CountCoverage(zip( 

399 self._variables.values(), 

400 self._functions.values() 

401 )) 

402 

403 super().CalculateCoverage() 

404 

405 def Aggregate(self) -> None: 

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

407 self._aggregatedTotal = self._total 

408 self._aggregatedExcluded = self._excluded 

409 self._aggregatedIgnored = self._ignored 

410 self._aggregatedExpected = self._expected 

411 self._aggregatedCovered = self._covered 

412 self._aggregatedUncovered = self._uncovered 

413 

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

415 pkg.Aggregate() 

416 self._fileCount += pkg._fileCount 

417 self._aggregatedTotal += pkg._total 

418 self._aggregatedExcluded += pkg._excluded 

419 self._aggregatedIgnored += pkg._ignored 

420 self._aggregatedExpected += pkg._expected 

421 self._aggregatedCovered += pkg._covered 

422 self._aggregatedUncovered += pkg._uncovered 

423 

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

425 mod.Aggregate() 

426 self._aggregatedTotal += mod._total 

427 self._aggregatedExcluded += mod._excluded 

428 self._aggregatedIgnored += mod._ignored 

429 self._aggregatedExpected += mod._expected 

430 self._aggregatedCovered += mod._covered 

431 self._aggregatedUncovered += mod._uncovered 

432 

433 super().Aggregate() 

434 

435 def __str__(self) -> str: 

436 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%}>" 

437 

438 

439@export 

440class DocStrCoverageError(DocCoverageException): 

441 pass 

442 

443 

444@export 

445class DocStrCoverage(metaclass=ExtendedType): 

446 """ 

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

448 """ 

449 from docstr_coverage import ResultCollection 

450 

451 _packageName: str 

452 _searchDirectory: Path 

453 _moduleFiles: List[Path] 

454 _coverageReport: ResultCollection 

455 

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

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

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

459 

460 self._searchDirectory = directory 

461 self._packageName = packageName 

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

463 

464 @readonly 

465 def SearchDirectories(self) -> Path: 

466 return self._searchDirectory 

467 

468 @readonly 

469 def PackageName(self) -> str: 

470 return self._packageName 

471 

472 @readonly 

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

474 return self._moduleFiles 

475 

476 @readonly 

477 def CoverageReport(self) -> ResultCollection: 

478 return self._coverageReport 

479 

480 def Analyze(self) -> ResultCollection: 

481 from docstr_coverage import analyze, ResultCollection 

482 

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

484 return self._coverageReport 

485 

486 def Convert(self) -> PackageCoverage: 

487 from docstr_coverage.result_collection import FileCount 

488 

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

490 

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

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

493 perFileResult: FileCount = value.count_aggregate() 

494 

495 moduleName = path.stem 

496 modulePath = path.parent.parts 

497 

498 currentCoverageObject: AggregatedCoverage = rootPackageCoverage 

499 for packageName in modulePath: 

500 try: 

501 currentCoverageObject = currentCoverageObject[packageName] 

502 except KeyError: 

503 currentCoverageObject = PackageCoverage(packageName, path, currentCoverageObject) 

504 

505 if moduleName != "__init__": 

506 currentCoverageObject = ModuleCoverage(moduleName, path, currentCoverageObject) 

507 

508 currentCoverageObject._expected = perFileResult.needed 

509 currentCoverageObject._covered = perFileResult.found 

510 currentCoverageObject._uncovered = perFileResult.missing 

511 

512 if currentCoverageObject._expected != 0: 

513 currentCoverageObject._coverage = currentCoverageObject._covered / currentCoverageObject._expected 

514 else: 

515 currentCoverageObject._coverage = 1.0 

516 

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

518 currentCoverageObject._coverage = -2.0 

519 

520 return rootPackageCoverage 

521 

522 del ResultCollection