Coverage for pyEDAA/Reports/DocumentationCoverage/DocStrCoverage.py: 92%
112 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-21 23:49 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-21 23:49 +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"""
33Converter of `docstr_coverage <https://github.com/HunterMcGushion/docstr_coverage>`__'s analysis to the documentation
34coverage model.
36docstr_coverage counts modules, classes and functions only - no fields or variables -, and reports each by an
37identifier of its own and its parent's name, without its kind or line. So the converter rebuilds the hierarchy from
38these identifiers, and every item but a module or package is of kind
39:attr:`~pyEDAA.Reports.DocumentationCoverage.ItemKind.Other`.
41.. seealso::
43 :mod:`pyEDAA.Reports.DocumentationCoverage.Python`
44 |rarr| The reader of Python source files, which counts fields and variables too.
45"""
46from __future__ import annotations
48from datetime import timedelta
49from pathlib import Path
50from typing import Optional as Nullable
52from docstr_coverage import analyze, ResultCollection
53from docstr_coverage.result_collection import ExpectedDocstring
54from pyTooling.Common import getFullyQualifiedName
55from pyTooling.Decorators import export, readonly
56from pyTooling.MetaClasses import ExtendedType
57from pyTooling.Stopwatch import Stopwatch
59from pyEDAA.Reports.DocumentationCoverage import CoverageState, DocCoverageError, DocumentationReport, Item, ItemKind
62@export
63class DocStrCoverageError(DocCoverageError):
64 """An analysis by docstr_coverage can't be run or converted."""
67@export
68class DocStrCoverage(metaclass=ExtendedType, slots=True):
69 """
70 Runs docstr_coverage's analysis on a package's directory, and converts it to the documentation coverage model.
71 """
73 _packageName: str #: Name of the package.
74 _searchDirectory: Path #: The directory searched for Python source files.
75 _moduleFiles: list[Path] #: The Python source files found.
76 _coverageReport: Nullable[ResultCollection] #: docstr_coverage's analysis, or ``None`` before :meth:`Analyze`.
77 _analysisDuration: Nullable[timedelta] #: Duration of :meth:`Analyze`, or ``None`` before it ran.
78 _conversionDuration: Nullable[timedelta] #: Duration of :meth:`Convert`, or ``None`` before it ran.
80 def __init__(self, packageName: str, directory: Path) -> None:
81 """
82 Initializes the analysis of a package's directory, and searches it for Python source files.
84 :param packageName: Name of the package, which is the name of the top-level item, e.g. ``myPackage``.
85 :param directory: The package's directory.
86 :raises ValueError: If parameter ``packageName`` is ``None``.
87 :raises TypeError: If parameter ``packageName`` isn't of type :class:`str`.
88 :raises ValueError: If parameter ``packageName`` is empty.
89 :raises ValueError: If parameter ``directory`` is ``None``.
90 :raises TypeError: If parameter ``directory`` isn't of type :class:`~pathlib.Path`.
91 :raises DocStrCoverageError: If the directory doesn't exist.
92 """
93 if packageName is None:
94 raise ValueError("Parameter 'packageName' is None.")
95 elif not isinstance(packageName, str): 95 ↛ 96line 95 didn't jump to line 96 because the condition on line 95 was never true
96 ex = TypeError("Parameter 'packageName' is not of type 'str'.")
97 ex.add_note(f"Got type '{getFullyQualifiedName(packageName)}'.")
98 raise ex
99 elif packageName == "": 99 ↛ 100line 99 didn't jump to line 100 because the condition on line 99 was never true
100 raise ValueError("Parameter 'packageName' is empty.")
102 if directory is None: 102 ↛ 103line 102 didn't jump to line 103 because the condition on line 102 was never true
103 raise ValueError("Parameter 'directory' is None.")
104 elif not isinstance(directory, Path):
105 ex = TypeError("Parameter 'directory' is not of type 'Path'.")
106 ex.add_note(f"Got type '{getFullyQualifiedName(directory)}'.")
107 raise ex
108 elif not directory.exists():
109 raise DocStrCoverageError(f"Package source directory '{directory.as_posix()}' does not exist.") \
110 from FileNotFoundError(f"Directory '{directory.as_posix()}' does not exist.")
112 self._packageName = packageName
113 self._searchDirectory = directory
114 self._moduleFiles = list(directory.glob("**/*.py"))
115 self._coverageReport = None
116 self._analysisDuration = None
117 self._conversionDuration = None
119 @readonly
120 def SearchDirectory(self) -> Path:
121 """
122 Read-only property to access the directory searched for Python source files (:attr:`_searchDirectory`).
124 :returns: Path to the search directory.
125 """
126 return self._searchDirectory
128 @readonly
129 def PackageName(self) -> str:
130 """
131 Read-only property to access the name of the analyzed package (:attr:`_packageName`).
133 :returns: Name of the package.
134 """
135 return self._packageName
137 @readonly
138 def ModuleFiles(self) -> list[Path]:
139 """
140 Read-only property to access the Python source files found in the search directory (:attr:`_moduleFiles`).
142 :returns: List of module file paths.
143 """
144 return self._moduleFiles
146 @readonly
147 def CoverageReport(self) -> Nullable[ResultCollection]:
148 """
149 Read-only property to access docstr_coverage's analysis (:attr:`_coverageReport`).
151 :returns: The analyzer's result collection, or ``None`` before :meth:`Analyze` ran.
152 """
153 return self._coverageReport
155 @readonly
156 def AnalysisDuration(self) -> Nullable[timedelta]:
157 """
158 Read-only property to access the duration of docstr_coverage's analysis (:attr:`_analysisDuration`).
160 :returns: The duration, or ``None`` before :meth:`Analyze` ran.
161 """
162 return self._analysisDuration
164 @readonly
165 def ModelConversionDuration(self) -> Nullable[timedelta]:
166 """
167 Read-only property to access the duration of :meth:`Convert`: building the documentation coverage report
168 (:attr:`_conversionDuration`).
170 :returns: The duration, or ``None`` before :meth:`Convert` ran.
171 """
172 return self._conversionDuration
174 def Analyze(self) -> ResultCollection:
175 """
176 Run docstr_coverage's analysis on the Python source files found, and measure its duration.
178 :returns: The analyzer's result collection.
179 """
180 with Stopwatch() as stopwatch:
181 self._coverageReport = analyze(self._moduleFiles, show_progress=False)
183 self._analysisDuration = stopwatch.Duration
184 return self._coverageReport
186 def Convert(self) -> DocumentationReport:
187 """
188 Convert docstr_coverage's analysis to a documentation coverage report, aggregate it, and measure the duration.
190 The report's :attr:`~pyEDAA.Reports.DocumentationCoverage.DocumentationReport.AnalysisDuration` is the
191 duration of docstr_coverage's analysis.
193 A source file is a module, an ``__init__.py`` the package of its directory, and a directory without
194 ``__init__.py`` a package without anything to document. docstr_coverage's identifier of an item names its parent:
195 the item is added to the innermost item of that name, which encloses the item reported before.
197 :returns: The report; its top-level item is the package.
198 :raises DocStrCoverageError: If :meth:`Analyze` didn't run.
199 """
200 if self._coverageReport is None:
201 raise DocStrCoverageError("docstr_coverage's analysis didn't run.")
203 def state(expected: ExpectedDocstring) -> CoverageState:
204 """
205 Nested function returning the coverage state of an expected doc-string.
207 :param expected: docstr_coverage's expected doc-string.
208 :returns: The coverage state.
209 """
210 if expected.has_docstring:
211 documentation = CoverageState.Documented | CoverageState.Covered
212 else:
213 documentation = CoverageState.Undocumented
215 return (documentation | CoverageState.Ignored) if expected.ignore_reason else documentation
217 with Stopwatch() as stopwatch:
218 report = DocumentationReport(Path(self._packageName))
219 packages: dict[tuple[str, ...], Item] = {}
221 fileResults = {
222 Path(path).relative_to(self._searchDirectory): fileResult for path, fileResult in self._coverageReport.files()
223 }
224 for path in sorted(fileResults, key=lambda path: (path.parent.parts, path.name != "__init__.py", path.name)):
225 directoryParts = path.parent.parts
226 parent: Item | DocumentationReport = report
227 for depth in range(len(directoryParts) + 1):
228 if (package := packages.get(directoryParts[:depth], None)) is None:
229 if depth == len(directoryParts) and path.name == "__init__.py":
230 break
232 packageName = directoryParts[depth - 1] if depth > 0 else self._packageName
233 package = Item(packageName, ItemKind.Package, CoverageState.Empty, parent=parent)
234 packages[directoryParts[:depth]] = package
236 parent = package
238 moduleState = CoverageState.Empty
239 expectedDocstrings: list[ExpectedDocstring] = []
240 for expected in fileResults[path].expected_docstrings():
241 if expected.node_identifier == "module docstring":
242 moduleState = state(expected)
243 else:
244 expectedDocstrings.append(expected)
246 file = report.GetOrAddFile(path)
247 if path.name != "__init__.py":
248 module = Item(path.stem, ItemKind.Module, moduleState, file=file, parent=parent)
249 else:
250 packageName = directoryParts[-1] if len(directoryParts) > 0 else self._packageName
251 module = Item(packageName, ItemKind.Package, moduleState, file=file, parent=parent)
252 packages[directoryParts] = module
254 enclosing: list[Item] = [module]
255 for expected in expectedDocstrings:
256 parentName, _, name = expected.node_identifier.rpartition(".")
257 while len(enclosing) > 1 and (parentName == "" or enclosing[-1]._name != parentName):
258 enclosing.pop()
260 enclosing.append(Item(name, ItemKind.Other, state(expected), file=file, parent=enclosing[-1]))
262 report.Aggregate()
264 report._analysisDuration = self._analysisDuration
265 self._conversionDuration = stopwatch.Duration
267 return report