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

320 statements  

« prev     ^ index     » next       coverage.py v7.8.0, created at 2025-05-16 22:20 +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 assert self._aggregatedUncovered == self._aggregatedExpected - self._aggregatedCovered 

210 

211 if self._aggregatedExpected != 0: 

212 self._aggregatedCoverage = self._aggregatedCovered / self._aggregatedExpected 

213 else: 

214 self._aggregatedCoverage = 1.0 

215 

216 

217@export 

218class ClassCoverage(Class, Coverage): 

219 """ 

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

221 """ 

222 _fields: Dict[str, CoverageState] 

223 _methods: Dict[str, CoverageState] 

224 _classes: Dict[str, "ClassCoverage"] 

225 

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

227 super().__init__(name, parent) 

228 Coverage.__init__(self) 

229 

230 if parent is not None: 

231 parent._classes[name] = self 

232 

233 self._fields = {} 

234 self._methods = {} 

235 self._classes = {} 

236 

237 @readonly 

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

239 return self._fields 

240 

241 @readonly 

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

243 return self._methods 

244 

245 @readonly 

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

247 return self._classes 

248 

249 def CalculateCoverage(self) -> None: 

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

251 cls.CalculateCoverage() 

252 

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

254 self._CountCoverage(zip( 

255 self._fields.values(), 

256 self._methods.values() 

257 )) 

258 

259 super().CalculateCoverage() 

260 

261 def __str__(self) -> str: 

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

263 

264 

265@export 

266class ModuleCoverage(Module, AggregatedCoverage): 

267 """ 

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

269 """ 

270 _variables: Dict[str, CoverageState] 

271 _functions: Dict[str, CoverageState] 

272 _classes: Dict[str, ClassCoverage] 

273 

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

275 super().__init__(name, parent) 

276 AggregatedCoverage.__init__(self, file) 

277 

278 if parent is not None: 

279 parent._modules[name] = self 

280 

281 self._file = file 

282 self._variables = {} 

283 self._functions = {} 

284 self._classes = {} 

285 

286 @readonly 

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

288 return self._variables 

289 

290 @readonly 

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

292 return self._functions 

293 

294 @readonly 

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

296 return self._classes 

297 

298 def CalculateCoverage(self) -> None: 

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

300 cls.CalculateCoverage() 

301 

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

303 self._CountCoverage(zip( 

304 self._variables.values(), 

305 self._functions.values() 

306 )) 

307 

308 super().CalculateCoverage() 

309 

310 def Aggregate(self) -> None: 

311 self._aggregatedTotal = self._total 

312 self._aggregatedExcluded = self._excluded 

313 self._aggregatedIgnored = self._ignored 

314 self._aggregatedExpected = self._expected 

315 self._aggregatedCovered = self._covered 

316 self._aggregatedUncovered = self._uncovered 

317 

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

319 self._aggregatedTotal += cls._total 

320 self._aggregatedExcluded += cls._excluded 

321 self._aggregatedIgnored += cls._ignored 

322 self._aggregatedExpected += cls._expected 

323 self._aggregatedCovered += cls._covered 

324 self._aggregatedUncovered += cls._uncovered 

325 

326 super().Aggregate() 

327 

328 def __str__(self) -> str: 

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

330 

331 

332@export 

333class PackageCoverage(Package, AggregatedCoverage): 

334 """ 

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

336 """ 

337 _fileCount: int 

338 _variables: Dict[str, CoverageState] 

339 _functions: Dict[str, CoverageState] 

340 _classes: Dict[str, ClassCoverage] 

341 _modules: Dict[str, ModuleCoverage] 

342 _packages: Dict[str, "PackageCoverage"] 

343 

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

345 super().__init__(name, parent) 

346 AggregatedCoverage.__init__(self, file) 

347 

348 if parent is not None: 

349 parent._packages[name] = self 

350 

351 self._file = file 

352 self._fileCount = 1 

353 self._variables = {} 

354 self._functions = {} 

355 self._classes = {} 

356 self._modules = {} 

357 self._packages = {} 

358 

359 @readonly 

360 def FileCount(self) -> int: 

361 return self._fileCount 

362 

363 @readonly 

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

365 return self._variables 

366 

367 @readonly 

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

369 return self._functions 

370 

371 @readonly 

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

373 return self._classes 

374 

375 @readonly 

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

377 return self._modules 

378 

379 @readonly 

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

381 return self._packages 

382 

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

384 try: 

385 return self._modules[key] 

386 except KeyError: 

387 return self._packages[key] 

388 

389 def CalculateCoverage(self) -> None: 

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

391 cls.CalculateCoverage() 

392 

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

394 mod.CalculateCoverage() 

395 

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

397 pkg.CalculateCoverage() 

398 

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

400 self._CountCoverage(zip( 

401 self._variables.values(), 

402 self._functions.values() 

403 )) 

404 

405 super().CalculateCoverage() 

406 

407 def Aggregate(self) -> None: 

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

409 self._aggregatedTotal = self._total 

410 self._aggregatedExcluded = self._excluded 

411 self._aggregatedIgnored = self._ignored 

412 self._aggregatedExpected = self._expected 

413 self._aggregatedCovered = self._covered 

414 self._aggregatedUncovered = self._uncovered 

415 

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

417 pkg.Aggregate() 

418 self._fileCount += pkg._fileCount 

419 self._aggregatedTotal += pkg._total 

420 self._aggregatedExcluded += pkg._excluded 

421 self._aggregatedIgnored += pkg._ignored 

422 self._aggregatedExpected += pkg._expected 

423 self._aggregatedCovered += pkg._covered 

424 self._aggregatedUncovered += pkg._uncovered 

425 

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

427 mod.Aggregate() 

428 self._aggregatedTotal += mod._total 

429 self._aggregatedExcluded += mod._excluded 

430 self._aggregatedIgnored += mod._ignored 

431 self._aggregatedExpected += mod._expected 

432 self._aggregatedCovered += mod._covered 

433 self._aggregatedUncovered += mod._uncovered 

434 

435 super().Aggregate() 

436 

437 def __str__(self) -> str: 

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

439 

440 

441@export 

442class DocStrCoverageError(DocCoverageException): 

443 pass 

444 

445 

446@export 

447class DocStrCoverage(metaclass=ExtendedType): 

448 """ 

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

450 """ 

451 from docstr_coverage import ResultCollection 

452 

453 _packageName: str 

454 _searchDirectory: Path 

455 _moduleFiles: List[Path] 

456 _coverageReport: ResultCollection 

457 

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

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

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

461 

462 self._searchDirectory = directory 

463 self._packageName = packageName 

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

465 

466 @readonly 

467 def SearchDirectories(self) -> Path: 

468 return self._searchDirectory 

469 

470 @readonly 

471 def PackageName(self) -> str: 

472 return self._packageName 

473 

474 @readonly 

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

476 return self._moduleFiles 

477 

478 @readonly 

479 def CoverageReport(self) -> ResultCollection: 

480 return self._coverageReport 

481 

482 def Analyze(self) -> ResultCollection: 

483 from docstr_coverage import analyze, ResultCollection 

484 

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

486 return self._coverageReport 

487 

488 def Convert(self) -> PackageCoverage: 

489 from docstr_coverage.result_collection import FileCount 

490 

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

492 

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

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

495 perFileResult: FileCount = value.count_aggregate() 

496 

497 moduleName = path.stem 

498 modulePath = path.parent.parts 

499 

500 currentCoverageObject: AggregatedCoverage = rootPackageCoverage 

501 for packageName in modulePath: 

502 try: 

503 currentCoverageObject = currentCoverageObject[packageName] 

504 except KeyError: 

505 currentCoverageObject = PackageCoverage(packageName, path, currentCoverageObject) 

506 

507 if moduleName != "__init__": 

508 currentCoverageObject = ModuleCoverage(moduleName, path, currentCoverageObject) 

509 

510 currentCoverageObject._expected = perFileResult.needed 

511 currentCoverageObject._covered = perFileResult.found 

512 currentCoverageObject._uncovered = perFileResult.missing 

513 

514 if currentCoverageObject._expected != 0: 

515 currentCoverageObject._coverage = currentCoverageObject._covered / currentCoverageObject._expected 

516 else: 

517 currentCoverageObject._coverage = 1.0 

518 

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

520 currentCoverageObject._coverage = -2.0 

521 

522 return rootPackageCoverage 

523 

524 del ResultCollection