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
« 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
38from docstr_coverage import analyze, ResultCollection
39from docstr_coverage.result_collection import FileCount
41from pyTooling.Decorators import export, readonly
42from pyTooling.MetaClasses import ExtendedType
44from pyEDAA.Reports.DocumentationCoverage import Class, Module, Package, CoverageState, DocCoverageException
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.
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 |%|.
56 All coverable items
57 total = excluded + ignored + expected
59 All expected items
60 expected = covered + uncovered
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
72 _coverage: float
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
82 self._coverage = -1.0
84 @readonly
85 def Total(self) -> int:
86 """
87 Read-only property to access the total number of documentable items.
89 :returns: Total number of items.
90 """
91 return self._total
93 @readonly
94 def Excluded(self) -> int:
95 """
96 Read-only property to access the number of items excluded from the analysis.
98 :returns: Number of excluded items.
99 """
100 return self._excluded
102 @readonly
103 def Ignored(self) -> int:
104 """
105 Read-only property to access the number of items ignored by the analysis.
107 :returns: Number of ignored items.
108 """
109 return self._ignored
111 @readonly
112 def Expected(self) -> int:
113 """
114 Read-only property to access the number of items expected to be documented.
116 :returns: Number of expected items.
117 """
118 return self._expected
120 @readonly
121 def Covered(self) -> int:
122 """
123 Read-only property to access the number of documented items.
125 :returns: Number of covered items.
126 """
127 return self._covered
129 @readonly
130 def Uncovered(self) -> int:
131 """
132 Read-only property to access the number of undocumented items.
134 :returns: Number of uncovered items.
135 """
136 return self._uncovered
138 @readonly
139 def Coverage(self) -> float:
140 """
141 Read-only property to access the ratio of covered to expected items.
143 :returns: Documentation coverage in the range 0.0 to 1.0.
144 """
145 return self._coverage
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
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"")
164 total += 1
166 if CoverageState.Excluded in coverageState:
167 excluded += 1
168 elif CoverageState.Ignored in coverageState:
169 ignored += 1
171 expected += 1
172 if CoverageState.Covered in coverageState:
173 covered += 1
175 return total, excluded, ignored, expected, covered
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.
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 |%|.
188 In addition, all previously mentioned metrics are collected as *aggregated...*, too. |br|
190 All coverable items
191 total = excluded + ignored + expected
193 All expected items
194 expected = covered + uncovered
196 Coverage [0.00..1.00]
197 coverage = covered / expected
198 """
199 _file: Path
201 _aggregatedTotal: int
202 _aggregatedExcluded: int
203 _aggregatedIgnored: int
204 _aggregatedExpected: int
205 _aggregatedCovered: int
206 _aggregatedUncovered: int
208 _aggregatedCoverage: float
210 def __init__(self, file: Path) -> None:
211 super().__init__()
212 self._file = file
214 @readonly
215 def File(self) -> Path:
216 """
217 Read-only property to access the file this coverage was computed from.
219 :returns: Path to the analyzed file.
220 """
221 return self._file
223 @readonly
224 def AggregatedTotal(self) -> int:
225 """
226 Read-only property to access the total number of documentable items, including all children.
228 :returns: Aggregated total number of items.
229 """
230 return self._aggregatedTotal
232 @readonly
233 def AggregatedExcluded(self) -> int:
234 """
235 Read-only property to access the number of excluded items, including all children.
237 :returns: Aggregated number of excluded items.
238 """
239 return self._aggregatedExcluded
241 @readonly
242 def AggregatedIgnored(self) -> int:
243 """
244 Read-only property to access the number of ignored items, including all children.
246 :returns: Aggregated number of ignored items.
247 """
248 return self._aggregatedIgnored
250 @readonly
251 def AggregatedExpected(self) -> int:
252 """
253 Read-only property to access the number of expected items, including all children.
255 :returns: Aggregated number of expected items.
256 """
257 return self._aggregatedExpected
259 @readonly
260 def AggregatedCovered(self) -> int:
261 """
262 Read-only property to access the number of documented items, including all children.
264 :returns: Aggregated number of covered items.
265 """
266 return self._aggregatedCovered
268 @readonly
269 def AggregatedUncovered(self) -> int:
270 """
271 Read-only property to access the number of undocumented items, including all children.
273 :returns: Aggregated number of uncovered items.
274 """
275 return self._aggregatedUncovered
277 @readonly
278 def AggregatedCoverage(self) -> float:
279 """
280 Read-only property to access the coverage ratio, including all children.
282 :returns: Aggregated documentation coverage in the range 0.0 to 1.0.
283 """
284 return self._aggregatedCoverage
286 def Aggregate(self) -> None:
287 if self._aggregatedExpected != 0:
288 self._aggregatedCoverage = self._aggregatedCovered / self._aggregatedExpected
289 else:
290 self._aggregatedCoverage = 1.0
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"]
302 def __init__(self, name: str, parent: Union["PackageCoverage", "ClassCoverage", None] = None) -> None:
303 super().__init__(name, parent)
304 Coverage.__init__(self)
306 if parent is not None:
307 parent._classes[name] = self
309 self._fields = {}
310 self._methods = {}
311 self._classes = {}
313 @readonly
314 def Fields(self) -> Dict[str, CoverageState]:
315 """
316 Read-only property to access the coverage states of the class' fields.
318 :returns: Dictionary of field names and their coverage states.
319 """
320 return self._fields
322 @readonly
323 def Methods(self) -> Dict[str, CoverageState]:
324 """
325 Read-only property to access the coverage states of the class' methods.
327 :returns: Dictionary of method names and their coverage states.
328 """
329 return self._methods
331 @readonly
332 def Classes(self) -> Dict[str, "ClassCoverage"]:
333 """
334 Read-only property to access the class' nested classes.
336 :returns: Dictionary of class names and their coverage.
337 """
338 return self._classes
340 def CalculateCoverage(self) -> None:
341 for cls in self._classes.values():
342 cls.CalculateCoverage()
344 self._total, self._excluded, self._ignored, self._expected, self._covered = \
345 self._CountCoverage(zip(
346 self._fields.values(),
347 self._methods.values()
348 ))
350 super().CalculateCoverage()
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%}>"
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]
365 def __init__(self, name: str, file: Path, parent: Nullable["PackageCoverage"] = None) -> None:
366 super().__init__(name, parent)
367 AggregatedCoverage.__init__(self, file)
369 if parent is not None:
370 parent._modules[name] = self
372 self._file = file
373 self._variables = {}
374 self._functions = {}
375 self._classes = {}
377 @readonly
378 def Variables(self) -> Dict[str, CoverageState]:
379 """
380 Read-only property to access the coverage states of the module's variables.
382 :returns: Dictionary of variable names and their coverage states.
383 """
384 return self._variables
386 @readonly
387 def Functions(self) -> Dict[str, CoverageState]:
388 """
389 Read-only property to access the coverage states of the module's functions.
391 :returns: Dictionary of function names and their coverage states.
392 """
393 return self._functions
395 @readonly
396 def Classes(self) -> Dict[str, ClassCoverage]:
397 """
398 Read-only property to access the module's classes.
400 :returns: Dictionary of class names and their coverage.
401 """
402 return self._classes
404 def CalculateCoverage(self) -> None:
405 for cls in self._classes.values():
406 cls.CalculateCoverage()
408 self._total, self._excluded, self._ignored, self._expected, self._covered = \
409 self._CountCoverage(zip(
410 self._variables.values(),
411 self._functions.values()
412 ))
414 super().CalculateCoverage()
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
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
432 super().Aggregate()
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%}>"
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"]
450 def __init__(self, name: str, file: Path, parent: Nullable["PackageCoverage"] = None) -> None:
451 super().__init__(name, parent)
452 AggregatedCoverage.__init__(self, file)
454 if parent is not None:
455 parent._packages[name] = self
457 self._file = file
458 self._fileCount = 1
459 self._variables = {}
460 self._functions = {}
461 self._classes = {}
462 self._modules = {}
463 self._packages = {}
465 @readonly
466 def FileCount(self) -> int:
467 """
468 Read-only property to access the number of Python files in this package.
470 :returns: Number of files.
471 """
472 return self._fileCount
474 @readonly
475 def Variables(self) -> Dict[str, CoverageState]:
476 """
477 Read-only property to access the coverage states of the package's variables.
479 :returns: Dictionary of variable names and their coverage states.
480 """
481 return self._variables
483 @readonly
484 def Functions(self) -> Dict[str, CoverageState]:
485 """
486 Read-only property to access the coverage states of the package's functions.
488 :returns: Dictionary of function names and their coverage states.
489 """
490 return self._functions
492 @readonly
493 def Classes(self) -> Dict[str, ClassCoverage]:
494 """
495 Read-only property to access the package's classes.
497 :returns: Dictionary of class names and their coverage.
498 """
499 return self._classes
501 @readonly
502 def Modules(self) -> Dict[str, ModuleCoverage]:
503 """
504 Read-only property to access the package's modules.
506 :returns: Dictionary of module names and their coverage.
507 """
508 return self._modules
510 @readonly
511 def Packages(self) -> Dict[str, "PackageCoverage"]:
512 """
513 Read-only property to access the package's sub-packages.
515 :returns: Dictionary of package names and their coverage.
516 """
517 return self._packages
519 def __getitem__(self, key: str) -> Union["PackageCoverage", ModuleCoverage]:
520 try:
521 return self._modules[key]
522 except KeyError:
523 return self._packages[key]
525 def CalculateCoverage(self) -> None:
526 for cls in self._classes.values():
527 cls.CalculateCoverage()
529 for mod in self._modules.values():
530 mod.CalculateCoverage()
532 for pkg in self._packages.values():
533 pkg.CalculateCoverage()
535 self._total, self._excluded, self._ignored, self._expected, self._covered = \
536 self._CountCoverage(zip(
537 self._variables.values(),
538 self._functions.values()
539 ))
541 super().CalculateCoverage()
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
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
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
571 super().Aggregate()
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%}>"
577@export
578class DocStrCoverageError(DocCoverageException):
579 pass
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
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.")
596 self._searchDirectory = directory
597 self._packageName = packageName
598 self._moduleFiles = [file for file in directory.glob("**/*.py")]
600 @readonly
601 def SearchDirectories(self) -> Path:
602 """
603 Read-only property to access the directory the analysis searches for Python files.
605 :returns: Path to the search directory.
606 """
607 return self._searchDirectory
609 @readonly
610 def PackageName(self) -> str:
611 """
612 Read-only property to access the name of the analyzed package.
614 :returns: Name of the package.
615 """
616 return self._packageName
618 @readonly
619 def ModuleFiles(self) -> List[Path]:
620 """
621 Read-only property to access the Python files found in the search directory.
623 :returns: List of module file paths.
624 """
625 return self._moduleFiles
627 @readonly
628 def CoverageReport(self) -> ResultCollection:
629 """
630 Read-only property to access the raw report produced by ``docstr_coverage``.
632 :returns: The analyzer's result collection.
633 """
634 return self._coverageReport
636 def Analyze(self) -> ResultCollection:
637 self._coverageReport: ResultCollection = analyze(self._moduleFiles, show_progress=False)
638 return self._coverageReport
640 def Convert(self) -> PackageCoverage:
641 rootPackageCoverage = PackageCoverage(self._packageName, self._searchDirectory / "__init__.py")
643 for key, value in self._coverageReport.files():
644 path: Path = key.relative_to(self._searchDirectory)
645 perFileResult: FileCount = value.count_aggregate()
647 moduleName = path.stem
648 modulePath = path.parent.parts
650 currentCoverageObject: AggregatedCoverage = rootPackageCoverage
651 for packageName in modulePath:
652 try:
653 currentCoverageObject = currentCoverageObject[packageName]
654 except KeyError:
655 currentCoverageObject = PackageCoverage(packageName, path, currentCoverageObject)
657 if moduleName != "__init__":
658 currentCoverageObject = ModuleCoverage(moduleName, path, currentCoverageObject)
660 currentCoverageObject._expected = perFileResult.needed
661 currentCoverageObject._covered = perFileResult.found
662 currentCoverageObject._uncovered = perFileResult.missing
664 if currentCoverageObject._expected != 0:
665 currentCoverageObject._coverage = currentCoverageObject._covered / currentCoverageObject._expected
666 else:
667 currentCoverageObject._coverage = 1.0
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
672 return rootPackageCoverage