Source code for pyEDAA.Reports.DocumentationCoverage.DocStrCoverage

# ==================================================================================================================== #
#              _____ ____    _        _      ____                       _                                              #
#  _ __  _   _| ____|  _ \  / \      / \    |  _ \ ___ _ __   ___  _ __| |_ ___                                        #
# | '_ \| | | |  _| | | | |/ _ \    / _ \   | |_) / _ \ '_ \ / _ \| '__| __/ __|                                       #
# | |_) | |_| | |___| |_| / ___ \  / ___ \ _|  _ <  __/ |_) | (_) | |  | |_\__ \                                       #
# | .__/ \__, |_____|____/_/   \_\/_/   \_(_)_| \_\___| .__/ \___/|_|   \__|___/                                       #
# |_|    |___/                                        |_|                                                              #
# ==================================================================================================================== #
# 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                                                                                  #
# ==================================================================================================================== #
#
"""
Converter of `docstr_coverage <https://github.com/HunterMcGushion/docstr_coverage>`__'s analysis to the documentation
coverage model.

docstr_coverage counts modules, classes and functions only - no fields or variables -, and reports each by an
identifier of its own and its parent's name, without its kind or line. So the converter rebuilds the hierarchy from
these identifiers, and every item but a module or package is of kind
:attr:`~pyEDAA.Reports.DocumentationCoverage.ItemKind.Other`.

.. seealso::

   :mod:`pyEDAA.Reports.DocumentationCoverage.Python`
      |rarr| The reader of Python source files, which counts fields and variables too.
"""
from __future__                           import annotations

from datetime                             import timedelta
from pathlib                              import Path
from typing                               import Optional as Nullable

from docstr_coverage                      import analyze, ResultCollection
from docstr_coverage.result_collection    import ExpectedDocstring
from pyTooling.Common                     import getFullyQualifiedName
from pyTooling.Decorators                 import export, readonly
from pyTooling.MetaClasses                import ExtendedType
from pyTooling.Stopwatch                  import Stopwatch

from pyEDAA.Reports.DocumentationCoverage import CoverageState, DocCoverageError, DocumentationReport, Item, ItemKind


[docs] @export class DocStrCoverageError(DocCoverageError): """An analysis by docstr_coverage can't be run or converted."""
[docs] @export class DocStrCoverage(metaclass=ExtendedType, slots=True): """ Runs docstr_coverage's analysis on a package's directory, and converts it to the documentation coverage model. """ _packageName: str #: Name of the package. _searchDirectory: Path #: The directory searched for Python source files. _moduleFiles: list[Path] #: The Python source files found. _coverageReport: Nullable[ResultCollection] #: docstr_coverage's analysis, or ``None`` before :meth:`Analyze`. _analysisDuration: Nullable[timedelta] #: Duration of :meth:`Analyze`, or ``None`` before it ran. _conversionDuration: Nullable[timedelta] #: Duration of :meth:`Convert`, or ``None`` before it ran.
[docs] def __init__(self, packageName: str, directory: Path) -> None: """ Initializes the analysis of a package's directory, and searches it for Python source files. :param packageName: Name of the package, which is the name of the top-level item, e.g. ``myPackage``. :param directory: The package's directory. :raises ValueError: If parameter ``packageName`` is ``None``. :raises TypeError: If parameter ``packageName`` isn't of type :class:`str`. :raises ValueError: If parameter ``packageName`` is empty. :raises ValueError: If parameter ``directory`` is ``None``. :raises TypeError: If parameter ``directory`` isn't of type :class:`~pathlib.Path`. :raises DocStrCoverageError: If the directory doesn't exist. """ if packageName is None: raise ValueError("Parameter 'packageName' is None.") elif not isinstance(packageName, str): ex = TypeError("Parameter 'packageName' is not of type 'str'.") ex.add_note(f"Got type '{getFullyQualifiedName(packageName)}'.") raise ex elif packageName == "": raise ValueError("Parameter 'packageName' is empty.") if directory is None: raise ValueError("Parameter 'directory' is None.") elif not isinstance(directory, Path): ex = TypeError("Parameter 'directory' is not of type 'Path'.") ex.add_note(f"Got type '{getFullyQualifiedName(directory)}'.") raise ex elif not directory.exists(): raise DocStrCoverageError(f"Package source directory '{directory.as_posix()}' does not exist.") \ from FileNotFoundError(f"Directory '{directory.as_posix()}' does not exist.") self._packageName = packageName self._searchDirectory = directory self._moduleFiles = list(directory.glob("**/*.py")) self._coverageReport = None self._analysisDuration = None self._conversionDuration = None
@readonly def SearchDirectory(self) -> Path: """ Read-only property to access the directory searched for Python source files (:attr:`_searchDirectory`). :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 (:attr:`_packageName`). :returns: Name of the package. """ return self._packageName @readonly def ModuleFiles(self) -> list[Path]: """ Read-only property to access the Python source files found in the search directory (:attr:`_moduleFiles`). :returns: List of module file paths. """ return self._moduleFiles @readonly def CoverageReport(self) -> Nullable[ResultCollection]: """ Read-only property to access docstr_coverage's analysis (:attr:`_coverageReport`). :returns: The analyzer's result collection, or ``None`` before :meth:`Analyze` ran. """ return self._coverageReport @readonly def AnalysisDuration(self) -> Nullable[timedelta]: """ Read-only property to access the duration of docstr_coverage's analysis (:attr:`_analysisDuration`). :returns: The duration, or ``None`` before :meth:`Analyze` ran. """ return self._analysisDuration @readonly def ModelConversionDuration(self) -> Nullable[timedelta]: """ Read-only property to access the duration of :meth:`Convert`: building the documentation coverage report (:attr:`_conversionDuration`). :returns: The duration, or ``None`` before :meth:`Convert` ran. """ return self._conversionDuration
[docs] def Analyze(self) -> ResultCollection: """ Run docstr_coverage's analysis on the Python source files found, and measure its duration. :returns: The analyzer's result collection. """ with Stopwatch() as stopwatch: self._coverageReport = analyze(self._moduleFiles, show_progress=False) self._analysisDuration = stopwatch.Duration return self._coverageReport
[docs] def Convert(self) -> DocumentationReport: """ Convert docstr_coverage's analysis to a documentation coverage report, aggregate it, and measure the duration. The report's :attr:`~pyEDAA.Reports.DocumentationCoverage.DocumentationReport.AnalysisDuration` is the duration of docstr_coverage's analysis. A source file is a module, an ``__init__.py`` the package of its directory, and a directory without ``__init__.py`` a package without anything to document. docstr_coverage's identifier of an item names its parent: the item is added to the innermost item of that name, which encloses the item reported before. :returns: The report; its top-level item is the package. :raises DocStrCoverageError: If :meth:`Analyze` didn't run. """ if self._coverageReport is None: raise DocStrCoverageError("docstr_coverage's analysis didn't run.") def state(expected: ExpectedDocstring) -> CoverageState: """ Nested function returning the coverage state of an expected doc-string. :param expected: docstr_coverage's expected doc-string. :returns: The coverage state. """ if expected.has_docstring: documentation = CoverageState.Documented | CoverageState.Covered else: documentation = CoverageState.Undocumented return (documentation | CoverageState.Ignored) if expected.ignore_reason else documentation with Stopwatch() as stopwatch: report = DocumentationReport(Path(self._packageName)) packages: dict[tuple[str, ...], Item] = {} fileResults = { Path(path).relative_to(self._searchDirectory): fileResult for path, fileResult in self._coverageReport.files() } for path in sorted(fileResults, key=lambda path: (path.parent.parts, path.name != "__init__.py", path.name)): directoryParts = path.parent.parts parent: Item | DocumentationReport = report for depth in range(len(directoryParts) + 1): if (package := packages.get(directoryParts[:depth], None)) is None: if depth == len(directoryParts) and path.name == "__init__.py": break packageName = directoryParts[depth - 1] if depth > 0 else self._packageName package = Item(packageName, ItemKind.Package, CoverageState.Empty, parent=parent) packages[directoryParts[:depth]] = package parent = package moduleState = CoverageState.Empty expectedDocstrings: list[ExpectedDocstring] = [] for expected in fileResults[path].expected_docstrings(): if expected.node_identifier == "module docstring": moduleState = state(expected) else: expectedDocstrings.append(expected) file = report.GetOrAddFile(path) if path.name != "__init__.py": module = Item(path.stem, ItemKind.Module, moduleState, file=file, parent=parent) else: packageName = directoryParts[-1] if len(directoryParts) > 0 else self._packageName module = Item(packageName, ItemKind.Package, moduleState, file=file, parent=parent) packages[directoryParts] = module enclosing: list[Item] = [module] for expected in expectedDocstrings: parentName, _, name = expected.node_identifier.rpartition(".") while len(enclosing) > 1 and (parentName == "" or enclosing[-1]._name != parentName): enclosing.pop() enclosing.append(Item(name, ItemKind.Other, state(expected), file=file, parent=enclosing[-1])) report.Aggregate() report._analysisDuration = self._analysisDuration self._conversionDuration = stopwatch.Duration return report