# ==================================================================================================================== #
# _____ ____ _ _ ____ _ #
# _ __ _ _| ____| _ \ / \ / \ | _ \ ___ _ __ ___ _ __| |_ ___ #
# | '_ \| | | | _| | | | |/ _ \ / _ \ | |_) / _ \ '_ \ / _ \| '__| __/ __| #
# | |_) | |_| | |___| |_| / ___ \ / ___ \ _| _ < __/ |_) | (_) | | | |_\__ \ #
# | .__/ \__, |_____|____/_/ \_\/_/ \_(_)_| \_\___| .__/ \___/|_| \__|___/ #
# |_| |___/ |_| #
# ==================================================================================================================== #
# Authors: #
# Patrick Lehmann #
# #
# License: #
# ==================================================================================================================== #
# Copyright 2024-2026 Electronic Design Automation Abstraction (EDA²) #
# Copyright 2023-2023 Patrick Lehmann - Bötzingen, Germany #
# #
# Licensed under the Apache License, Version 2.0 (the "License"); #
# you may not use this file except in compliance with the License. #
# You may obtain a copy of the License at #
# #
# http://www.apache.org/licenses/LICENSE-2.0 #
# #
# Unless required by applicable law or agreed to in writing, software #
# distributed under the License is distributed on an "AS IS" BASIS, #
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. #
# See the License for the specific language governing permissions and #
# limitations under the License. #
# #
# SPDX-License-Identifier: Apache-2.0 #
# ==================================================================================================================== #
#
"""
**Abstract code documentation coverage data model for Python code.**
"""
from pathlib import Path
from typing import Optional as Nullable, Iterable, Dict, Union, Tuple, List
from docstr_coverage import analyze, ResultCollection
from docstr_coverage.result_collection import FileCount
from pyTooling.Decorators import export, readonly
from pyTooling.MetaClasses import ExtendedType
from pyEDAA.Reports.DocumentationCoverage import Class, Module, Package, CoverageState, DocCoverageException
[docs]
@export
class Coverage(metaclass=ExtendedType, mixin=True):
"""
This base-class for :class:`ClassCoverage` and :class:`AggregatedCoverage` represents a basic set of documentation coverage metrics.
Besides the *total* number of coverable items, it distinguishes items as *excluded*, *ignored*, and *expected*. |br|
Expected items are further distinguished into *covered* and *uncovered* items. |br|
If no item is expected, then *coverage* is always 100 |%|.
All coverable items
total = excluded + ignored + expected
All expected items
expected = covered + uncovered
Coverage [0.00..1.00]
coverage = covered / expected
"""
_total: int
_excluded: int
_ignored: int
_expected: int
_covered: int
_uncovered: int
_coverage: float
[docs]
def __init__(self) -> None:
self._total = 0
self._excluded = 0
self._ignored = 0
self._expected = 0
self._covered = 0
self._uncovered = 0
self._coverage = -1.0
@readonly
def Total(self) -> int:
"""
Read-only property to access the total number of documentable items.
:returns: Total number of items.
"""
return self._total
@readonly
def Excluded(self) -> int:
"""
Read-only property to access the number of items excluded from the analysis.
:returns: Number of excluded items.
"""
return self._excluded
@readonly
def Ignored(self) -> int:
"""
Read-only property to access the number of items ignored by the analysis.
:returns: Number of ignored items.
"""
return self._ignored
@readonly
def Expected(self) -> int:
"""
Read-only property to access the number of items expected to be documented.
:returns: Number of expected items.
"""
return self._expected
@readonly
def Covered(self) -> int:
"""
Read-only property to access the number of documented items.
:returns: Number of covered items.
"""
return self._covered
@readonly
def Uncovered(self) -> int:
"""
Read-only property to access the number of undocumented items.
:returns: Number of uncovered items.
"""
return self._uncovered
@readonly
def Coverage(self) -> float:
"""
Read-only property to access the ratio of covered to expected items.
:returns: Documentation coverage in the range 0.0 to 1.0.
"""
return self._coverage
def CalculateCoverage(self) -> None:
self._uncovered = self._expected - self._covered
if self._expected != 0:
self._coverage = self._covered / self._expected
else:
self._coverage = 1.0
def _CountCoverage(self, iterator: Iterable[CoverageState]) -> Tuple[int, int, int, int, int]:
total = 0
excluded = 0
ignored = 0
expected = 0
covered = 0
for coverageState in iterator:
if coverageState is CoverageState.Unknown:
raise Exception(f"")
total += 1
if CoverageState.Excluded in coverageState:
excluded += 1
elif CoverageState.Ignored in coverageState:
ignored += 1
expected += 1
if CoverageState.Covered in coverageState:
covered += 1
return total, excluded, ignored, expected, covered
[docs]
@export
class AggregatedCoverage(Coverage, mixin=True):
"""
This base-class for :class:`ModuleCoverage` and :class:`PackageCoverage` represents an extended set of documentation coverage metrics, especially with aggregated metrics.
As inherited from :class:`~Coverage`, it provides the *total* number of coverable items, which are distinguished into
*excluded*, *ignored*, and *expected* items. |br|
Expected items are further distinguished into *covered* and *uncovered* items. |br|
If no item is expected, then *coverage* and *aggregated coverage* are always 100 |%|.
In addition, all previously mentioned metrics are collected as *aggregated...*, too. |br|
All coverable items
total = excluded + ignored + expected
All expected items
expected = covered + uncovered
Coverage [0.00..1.00]
coverage = covered / expected
"""
_file: Path
_aggregatedTotal: int
_aggregatedExcluded: int
_aggregatedIgnored: int
_aggregatedExpected: int
_aggregatedCovered: int
_aggregatedUncovered: int
_aggregatedCoverage: float
[docs]
def __init__(self, file: Path) -> None:
super().__init__()
self._file = file
@readonly
def File(self) -> Path:
"""
Read-only property to access the file this coverage was computed from.
:returns: Path to the analyzed file.
"""
return self._file
@readonly
def AggregatedTotal(self) -> int:
"""
Read-only property to access the total number of documentable items, including all children.
:returns: Aggregated total number of items.
"""
return self._aggregatedTotal
@readonly
def AggregatedExcluded(self) -> int:
"""
Read-only property to access the number of excluded items, including all children.
:returns: Aggregated number of excluded items.
"""
return self._aggregatedExcluded
@readonly
def AggregatedIgnored(self) -> int:
"""
Read-only property to access the number of ignored items, including all children.
:returns: Aggregated number of ignored items.
"""
return self._aggregatedIgnored
@readonly
def AggregatedExpected(self) -> int:
"""
Read-only property to access the number of expected items, including all children.
:returns: Aggregated number of expected items.
"""
return self._aggregatedExpected
@readonly
def AggregatedCovered(self) -> int:
"""
Read-only property to access the number of documented items, including all children.
:returns: Aggregated number of covered items.
"""
return self._aggregatedCovered
@readonly
def AggregatedUncovered(self) -> int:
"""
Read-only property to access the number of undocumented items, including all children.
:returns: Aggregated number of uncovered items.
"""
return self._aggregatedUncovered
@readonly
def AggregatedCoverage(self) -> float:
"""
Read-only property to access the coverage ratio, including all children.
:returns: Aggregated documentation coverage in the range 0.0 to 1.0.
"""
return self._aggregatedCoverage
def Aggregate(self) -> None:
if self._aggregatedExpected != 0:
self._aggregatedCoverage = self._aggregatedCovered / self._aggregatedExpected
else:
self._aggregatedCoverage = 1.0
[docs]
@export
class ClassCoverage(Class, Coverage):
"""
This class represents the class documentation coverage for Python classes.
"""
_fields: Dict[str, CoverageState]
_methods: Dict[str, CoverageState]
_classes: Dict[str, "ClassCoverage"]
[docs]
def __init__(self, name: str, parent: Union["PackageCoverage", "ClassCoverage", None] = None) -> None:
super().__init__(name, parent)
Coverage.__init__(self)
if parent is not None:
parent._classes[name] = self
self._fields = {}
self._methods = {}
self._classes = {}
@readonly
def Fields(self) -> Dict[str, CoverageState]:
"""
Read-only property to access the coverage states of the class' fields.
:returns: Dictionary of field names and their coverage states.
"""
return self._fields
@readonly
def Methods(self) -> Dict[str, CoverageState]:
"""
Read-only property to access the coverage states of the class' methods.
:returns: Dictionary of method names and their coverage states.
"""
return self._methods
@readonly
def Classes(self) -> Dict[str, "ClassCoverage"]:
"""
Read-only property to access the class' nested classes.
:returns: Dictionary of class names and their coverage.
"""
return self._classes
def CalculateCoverage(self) -> None:
for cls in self._classes.values():
cls.CalculateCoverage()
self._total, self._excluded, self._ignored, self._expected, self._covered = \
self._CountCoverage(zip(
self._fields.values(),
self._methods.values()
))
super().CalculateCoverage()
[docs]
def __str__(self) -> str:
return f"<ClassCoverage - tot:{self._total}, ex:{self._excluded}, ig:{self._ignored}, exp:{self._expected}, cov:{self._covered}, un:{self._uncovered} => {self._coverage:.1%}>"
[docs]
@export
class ModuleCoverage(Module, AggregatedCoverage):
"""
This class represents the module documentation coverage for Python modules.
"""
_variables: Dict[str, CoverageState]
_functions: Dict[str, CoverageState]
_classes: Dict[str, ClassCoverage]
[docs]
def __init__(self, name: str, file: Path, parent: Nullable["PackageCoverage"] = None) -> None:
super().__init__(name, parent)
AggregatedCoverage.__init__(self, file)
if parent is not None:
parent._modules[name] = self
self._file = file
self._variables = {}
self._functions = {}
self._classes = {}
@readonly
def Variables(self) -> Dict[str, CoverageState]:
"""
Read-only property to access the coverage states of the module's variables.
:returns: Dictionary of variable names and their coverage states.
"""
return self._variables
@readonly
def Functions(self) -> Dict[str, CoverageState]:
"""
Read-only property to access the coverage states of the module's functions.
:returns: Dictionary of function names and their coverage states.
"""
return self._functions
@readonly
def Classes(self) -> Dict[str, ClassCoverage]:
"""
Read-only property to access the module's classes.
:returns: Dictionary of class names and their coverage.
"""
return self._classes
def CalculateCoverage(self) -> None:
for cls in self._classes.values():
cls.CalculateCoverage()
self._total, self._excluded, self._ignored, self._expected, self._covered = \
self._CountCoverage(zip(
self._variables.values(),
self._functions.values()
))
super().CalculateCoverage()
def Aggregate(self) -> None:
self._aggregatedTotal = self._total
self._aggregatedExcluded = self._excluded
self._aggregatedIgnored = self._ignored
self._aggregatedExpected = self._expected
self._aggregatedCovered = self._covered
self._aggregatedUncovered = self._uncovered
for cls in self._classes.values():
self._aggregatedTotal += cls._total
self._aggregatedExcluded += cls._excluded
self._aggregatedIgnored += cls._ignored
self._aggregatedExpected += cls._expected
self._aggregatedCovered += cls._covered
self._aggregatedUncovered += cls._uncovered
super().Aggregate()
[docs]
def __str__(self) -> str:
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%}>"
[docs]
@export
class PackageCoverage(Package, AggregatedCoverage):
"""
This class represents the package documentation coverage for Python packages.
"""
_fileCount: int
_variables: Dict[str, CoverageState]
_functions: Dict[str, CoverageState]
_classes: Dict[str, ClassCoverage]
_modules: Dict[str, ModuleCoverage]
_packages: Dict[str, "PackageCoverage"]
[docs]
def __init__(self, name: str, file: Path, parent: Nullable["PackageCoverage"] = None) -> None:
super().__init__(name, parent)
AggregatedCoverage.__init__(self, file)
if parent is not None:
parent._packages[name] = self
self._file = file
self._fileCount = 1
self._variables = {}
self._functions = {}
self._classes = {}
self._modules = {}
self._packages = {}
@readonly
def FileCount(self) -> int:
"""
Read-only property to access the number of Python files in this package.
:returns: Number of files.
"""
return self._fileCount
@readonly
def Variables(self) -> Dict[str, CoverageState]:
"""
Read-only property to access the coverage states of the package's variables.
:returns: Dictionary of variable names and their coverage states.
"""
return self._variables
@readonly
def Functions(self) -> Dict[str, CoverageState]:
"""
Read-only property to access the coverage states of the package's functions.
:returns: Dictionary of function names and their coverage states.
"""
return self._functions
@readonly
def Classes(self) -> Dict[str, ClassCoverage]:
"""
Read-only property to access the package's classes.
:returns: Dictionary of class names and their coverage.
"""
return self._classes
@readonly
def Modules(self) -> Dict[str, ModuleCoverage]:
"""
Read-only property to access the package's modules.
:returns: Dictionary of module names and their coverage.
"""
return self._modules
@readonly
def Packages(self) -> Dict[str, "PackageCoverage"]:
"""
Read-only property to access the package's sub-packages.
:returns: Dictionary of package names and their coverage.
"""
return self._packages
def __getitem__(self, key: str) -> Union["PackageCoverage", ModuleCoverage]:
try:
return self._modules[key]
except KeyError:
return self._packages[key]
def CalculateCoverage(self) -> None:
for cls in self._classes.values():
cls.CalculateCoverage()
for mod in self._modules.values():
mod.CalculateCoverage()
for pkg in self._packages.values():
pkg.CalculateCoverage()
self._total, self._excluded, self._ignored, self._expected, self._covered = \
self._CountCoverage(zip(
self._variables.values(),
self._functions.values()
))
super().CalculateCoverage()
def Aggregate(self) -> None:
self._fileCount = len(self._modules) + 1
self._aggregatedTotal = self._total
self._aggregatedExcluded = self._excluded
self._aggregatedIgnored = self._ignored
self._aggregatedExpected = self._expected
self._aggregatedCovered = self._covered
self._aggregatedUncovered = self._uncovered
for pkg in self._packages.values():
pkg.Aggregate()
self._fileCount += pkg._fileCount
self._aggregatedTotal += pkg._total
self._aggregatedExcluded += pkg._excluded
self._aggregatedIgnored += pkg._ignored
self._aggregatedExpected += pkg._expected
self._aggregatedCovered += pkg._covered
self._aggregatedUncovered += pkg._uncovered
for mod in self._modules.values():
mod.Aggregate()
self._aggregatedTotal += mod._total
self._aggregatedExcluded += mod._excluded
self._aggregatedIgnored += mod._ignored
self._aggregatedExpected += mod._expected
self._aggregatedCovered += mod._covered
self._aggregatedUncovered += mod._uncovered
super().Aggregate()
[docs]
def __str__(self) -> str:
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%}>"
[docs]
@export
class DocStrCoverageError(DocCoverageException):
pass
[docs]
@export
class DocStrCoverage(metaclass=ExtendedType):
"""
A wrapper class for the docstr_coverage package and it's analyzer producing a documentation coverage model.
"""
_packageName: str
_searchDirectory: Path
_moduleFiles: List[Path]
_coverageReport: ResultCollection
[docs]
def __init__(self, packageName: str, directory: Path) -> None:
if not directory.exists():
raise DocStrCoverageError(f"Package source directory '{directory}' does not exist.") from FileNotFoundError(f"Directory '{directory}' does not exist.")
self._searchDirectory = directory
self._packageName = packageName
self._moduleFiles = [file for file in directory.glob("**/*.py")]
@readonly
def SearchDirectories(self) -> Path:
"""
Read-only property to access the directory the analysis searches for Python files.
:returns: Path to the search directory.
"""
return self._searchDirectory
@readonly
def PackageName(self) -> str:
"""
Read-only property to access the name of the analyzed package.
:returns: Name of the package.
"""
return self._packageName
@readonly
def ModuleFiles(self) -> List[Path]:
"""
Read-only property to access the Python files found in the search directory.
:returns: List of module file paths.
"""
return self._moduleFiles
@readonly
def CoverageReport(self) -> ResultCollection:
"""
Read-only property to access the raw report produced by ``docstr_coverage``.
:returns: The analyzer's result collection.
"""
return self._coverageReport
def Analyze(self) -> ResultCollection:
self._coverageReport: ResultCollection = analyze(self._moduleFiles, show_progress=False)
return self._coverageReport
def Convert(self) -> PackageCoverage:
rootPackageCoverage = PackageCoverage(self._packageName, self._searchDirectory / "__init__.py")
for key, value in self._coverageReport.files():
path: Path = key.relative_to(self._searchDirectory)
perFileResult: FileCount = value.count_aggregate()
moduleName = path.stem
modulePath = path.parent.parts
currentCoverageObject: AggregatedCoverage = rootPackageCoverage
for packageName in modulePath:
try:
currentCoverageObject = currentCoverageObject[packageName]
except KeyError:
currentCoverageObject = PackageCoverage(packageName, path, currentCoverageObject)
if moduleName != "__init__":
currentCoverageObject = ModuleCoverage(moduleName, path, currentCoverageObject)
currentCoverageObject._expected = perFileResult.needed
currentCoverageObject._covered = perFileResult.found
currentCoverageObject._uncovered = perFileResult.missing
if currentCoverageObject._expected != 0:
currentCoverageObject._coverage = currentCoverageObject._covered / currentCoverageObject._expected
else:
currentCoverageObject._coverage = 1.0
if currentCoverageObject._uncovered != currentCoverageObject._expected - currentCoverageObject._covered:
currentCoverageObject._coverage = -2.0
return rootPackageCoverage