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

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. 

35 

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`. 

40 

41.. seealso:: 

42 

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 

47 

48from datetime import timedelta 

49from pathlib import Path 

50from typing import Optional as Nullable 

51 

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 

58 

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

60 

61 

62@export 

63class DocStrCoverageError(DocCoverageError): 

64 """An analysis by docstr_coverage can't be run or converted.""" 

65 

66 

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 """ 

72 

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. 

79 

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. 

83 

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.") 

101 

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.") 

111 

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 

118 

119 @readonly 

120 def SearchDirectory(self) -> Path: 

121 """ 

122 Read-only property to access the directory searched for Python source files (:attr:`_searchDirectory`). 

123 

124 :returns: Path to the search directory. 

125 """ 

126 return self._searchDirectory 

127 

128 @readonly 

129 def PackageName(self) -> str: 

130 """ 

131 Read-only property to access the name of the analyzed package (:attr:`_packageName`). 

132 

133 :returns: Name of the package. 

134 """ 

135 return self._packageName 

136 

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`). 

141 

142 :returns: List of module file paths. 

143 """ 

144 return self._moduleFiles 

145 

146 @readonly 

147 def CoverageReport(self) -> Nullable[ResultCollection]: 

148 """ 

149 Read-only property to access docstr_coverage's analysis (:attr:`_coverageReport`). 

150 

151 :returns: The analyzer's result collection, or ``None`` before :meth:`Analyze` ran. 

152 """ 

153 return self._coverageReport 

154 

155 @readonly 

156 def AnalysisDuration(self) -> Nullable[timedelta]: 

157 """ 

158 Read-only property to access the duration of docstr_coverage's analysis (:attr:`_analysisDuration`). 

159 

160 :returns: The duration, or ``None`` before :meth:`Analyze` ran. 

161 """ 

162 return self._analysisDuration 

163 

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`). 

169 

170 :returns: The duration, or ``None`` before :meth:`Convert` ran. 

171 """ 

172 return self._conversionDuration 

173 

174 def Analyze(self) -> ResultCollection: 

175 """ 

176 Run docstr_coverage's analysis on the Python source files found, and measure its duration. 

177 

178 :returns: The analyzer's result collection. 

179 """ 

180 with Stopwatch() as stopwatch: 

181 self._coverageReport = analyze(self._moduleFiles, show_progress=False) 

182 

183 self._analysisDuration = stopwatch.Duration 

184 return self._coverageReport 

185 

186 def Convert(self) -> DocumentationReport: 

187 """ 

188 Convert docstr_coverage's analysis to a documentation coverage report, aggregate it, and measure the duration. 

189 

190 The report's :attr:`~pyEDAA.Reports.DocumentationCoverage.DocumentationReport.AnalysisDuration` is the 

191 duration of docstr_coverage's analysis. 

192 

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. 

196 

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.") 

202 

203 def state(expected: ExpectedDocstring) -> CoverageState: 

204 """ 

205 Nested function returning the coverage state of an expected doc-string. 

206 

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 

214 

215 return (documentation | CoverageState.Ignored) if expected.ignore_reason else documentation 

216 

217 with Stopwatch() as stopwatch: 

218 report = DocumentationReport(Path(self._packageName)) 

219 packages: dict[tuple[str, ...], Item] = {} 

220 

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 

231 

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 

235 

236 parent = package 

237 

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) 

245 

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 

253 

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() 

259 

260 enclosing.append(Item(name, ItemKind.Other, state(expected), file=file, parent=enclosing[-1])) 

261 

262 report.Aggregate() 

263 

264 report._analysisDuration = self._analysisDuration 

265 self._conversionDuration = stopwatch.Duration 

266 

267 return report