Coverage for sphinx_reports/DocCoverage.py: 22%

225 statements  

« prev     ^ index     » next       coverage.py v7.8.2, created at 2025-06-02 18:49 +0000

1# ==================================================================================================================== # 

2# _ _ _ # 

3# ___ _ __ | |__ (_)_ __ __ __ _ __ ___ _ __ ___ _ __| |_ ___ # 

4# / __| '_ \| '_ \| | '_ \\ \/ /____| '__/ _ \ '_ \ / _ \| '__| __/ __| # 

5# \__ \ |_) | | | | | | | |> <_____| | | __/ |_) | (_) | | | |_\__ \ # 

6# |___/ .__/|_| |_|_|_| |_/_/\_\ |_| \___| .__/ \___/|_| \__|___/ # 

7# |_| |_| # 

8# ==================================================================================================================== # 

9# Authors: # 

10# Patrick Lehmann # 

11# # 

12# License: # 

13# ==================================================================================================================== # 

14# Copyright 2023-2025 Patrick Lehmann - Bötzingen, Germany # 

15# # 

16# Licensed under the Apache License, Version 2.0 (the "License"); # 

17# you may not use this file except in compliance with the License. # 

18# You may obtain a copy of the License at # 

19# # 

20# http://www.apache.org/licenses/LICENSE-2.0 # 

21# # 

22# Unless required by applicable law or agreed to in writing, software # 

23# distributed under the License is distributed on an "AS IS" BASIS, # 

24# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # 

25# See the License for the specific language governing permissions and # 

26# limitations under the License. # 

27# # 

28# SPDX-License-Identifier: Apache-2.0 # 

29# ==================================================================================================================== # 

30# 

31""" 

32**Report documentation coverage as Sphinx documentation page(s).** 

33""" 

34from pathlib import Path 

35from typing import Dict, Tuple, Any, List, Mapping, Generator, TypedDict, Union, ClassVar 

36 

37from docutils import nodes 

38from sphinx.application import Sphinx 

39from sphinx.config import Config 

40from pyTooling.Decorators import export 

41from pyEDAA.Reports.DocumentationCoverage.Python import DocStrCoverage as DocStrCovAnalyzer 

42from pyEDAA.Reports.DocumentationCoverage.Python import PackageCoverage, AggregatedCoverage 

43 

44from sphinx_reports.Common import ReportExtensionError, LegendStyle 

45from sphinx_reports.Sphinx import strip, BaseDirective 

46 

47 

48class package_DictType(TypedDict): 

49 name: str 

50 directory: Path 

51 fail_below: int 

52 levels: Union[str, Dict[Union[int, str], Dict[str, str]]] 

53 

54 

55@export 

56class DocCoverageBase(BaseDirective): 

57 option_spec = { 

58 "class": strip, 

59 "reportid": strip, 

60 } 

61 

62 defaultCoverageDefinitions = { 

63 "default": { 

64 10: {"class": "report-cov-below10", "desc": "almost undocumented"}, 

65 20: {"class": "report-cov-below20", "desc": "almost undocumented"}, 

66 30: {"class": "report-cov-below30", "desc": "almost undocumented"}, 

67 40: {"class": "report-cov-below40", "desc": "poorly documented"}, 

68 50: {"class": "report-cov-below50", "desc": "poorly documented"}, 

69 60: {"class": "report-cov-below60", "desc": "roughly documented"}, 

70 70: {"class": "report-cov-below70", "desc": "roughly documented"}, 

71 80: {"class": "report-cov-below80", "desc": "roughly documented"}, 

72 85: {"class": "report-cov-below85", "desc": "well documented"}, 

73 90: {"class": "report-cov-below90", "desc": "well documented"}, 

74 95: {"class": "report-cov-below95", "desc": "well documented"}, 

75 100: {"class": "report-cov-below100", "desc": "excellent documented"}, 

76 "error": {"class": "report-cov-error", "desc": "internal error"}, 

77 } 

78 } 

79 

80 configPrefix: str = "doccov" 

81 configValues: Dict[str, Tuple[Any, str, Any]] = { 

82 f"{configPrefix}_packages": ({}, "env", Dict), 

83 f"{configPrefix}_levels": (defaultCoverageDefinitions, "env", Dict), 

84 } #: A dictionary of all configuration values used by documentation coverage directives. 

85 

86 _coverageLevelDefinitions: ClassVar[Dict[str, Dict[Union[int, str], Dict[str, str]]]] = {} 

87 _packageConfigurations: ClassVar[Dict[str, package_DictType]] = {} 

88 

89 _cssClasses: List[str] 

90 _reportID: str 

91 _levels: Dict[Union[int, str], Dict[str, str]] 

92 

93 def _CheckOptions(self) -> None: 

94 """ 

95 Parse all directive options or use default values. 

96 """ 

97 cssClasses = self._ParseStringOption("class", "", r"(\w+)?( +\w+)*") 

98 

99 self._reportID = self._ParseStringOption("reportid") 

100 self._cssClasses = [] if cssClasses == "" else cssClasses.split(" ") 

101 

102 @classmethod 

103 def CheckConfiguration(cls, sphinxApplication: Sphinx, sphinxConfiguration: Config) -> None: 

104 """ 

105 Check configuration fields and load necessary values. 

106 

107 :param sphinxApplication: Sphinx application instance. 

108 :param sphinxConfiguration: Sphinx configuration instance. 

109 """ 

110 cls._CheckLevelsConfiguration(sphinxConfiguration) 

111 cls._CheckPackagesConfiguration(sphinxConfiguration) 

112 

113 @classmethod 

114 def _CheckLevelsConfiguration(cls, sphinxConfiguration: Config) -> None: 

115 from sphinx_reports import ReportDomain 

116 

117 variableName = f"{ReportDomain.name}_{cls.configPrefix}_levels" 

118 

119 try: 

120 coverageLevelDefinitions: Dict[str, package_DictType] = sphinxConfiguration[f"{ReportDomain.name}_{cls.configPrefix}_levels"] 

121 except (KeyError, AttributeError) as ex: 

122 raise ReportExtensionError(f"Configuration option '{variableName}' is not configured.") from ex 

123 

124 if "default" not in coverageLevelDefinitions: 

125 cls._coverageLevelDefinitions["default"] = cls.defaultCoverageDefinitions["default"] 

126 

127 for key, coverageLevelDefinition in coverageLevelDefinitions.items(): 

128 configurationName = f"conf.py: {variableName}:[{key}]" 

129 

130 if 100 not in coverageLevelDefinition: 

131 raise ReportExtensionError(f"{configurationName}[100]: Configuration is missing.") 

132 elif "error" not in coverageLevelDefinition: 

133 raise ReportExtensionError(f"{configurationName}[error]: Configuration is missing.") 

134 

135 cls._coverageLevelDefinitions[key] = {} 

136 

137 for level, levelConfig in coverageLevelDefinition.items(): 

138 try: 

139 if isinstance(level, str): 

140 if level != "error": 

141 raise ReportExtensionError(f"{configurationName}[{level}]: Level is a keyword, but not 'error'.") 

142 elif not (0.0 <= int(level) <= 100.0): 

143 raise ReportExtensionError(f"{configurationName}[{level}]: Level is out of range 0..100.") 

144 except ValueError as ex: 

145 raise ReportExtensionError(f"{configurationName}[{level}]: Level is not a keyword or an integer in range 0..100.") from ex 

146 

147 try: 

148 cssClass = levelConfig["class"] 

149 except KeyError as ex: 

150 raise ReportExtensionError(f"{configurationName}[{level}].class: CSS class is missing.") from ex 

151 

152 try: 

153 description = levelConfig["desc"] 

154 except KeyError as ex: 

155 raise ReportExtensionError(f"{configurationName}[{level}].desc: Description is missing.") from ex 

156 

157 cls._coverageLevelDefinitions[key][level] = { 

158 "class": cssClass, 

159 "desc": description 

160 } 

161 

162 @classmethod 

163 def _CheckPackagesConfiguration(cls, sphinxConfiguration: Config) -> None: 

164 from sphinx_reports import ReportDomain 

165 

166 variableName = f"{ReportDomain.name}_{cls.configPrefix}_packages" 

167 

168 try: 

169 allPackages: Dict[str, package_DictType] = sphinxConfiguration[f"{ReportDomain.name}_{cls.configPrefix}_packages"] 

170 except (KeyError, AttributeError) as ex: 

171 raise ReportExtensionError(f"Configuration option '{variableName}' is not configured.") from ex 

172 

173 for reportID, packageConfiguration in allPackages.items(): 

174 configurationName = f"conf.py: {variableName}:[{reportID}]" 

175 

176 try: 

177 packageName = packageConfiguration["name"] 

178 except KeyError as ex: 

179 raise ReportExtensionError(f"{configurationName}.name: Configuration is missing.") from ex 

180 

181 try: 

182 directory = Path(packageConfiguration["directory"]) 

183 except KeyError as ex: 

184 raise ReportExtensionError(f"{configurationName}.directory: Configuration is missing.") from ex 

185 

186 if not directory.exists(): 

187 raise ReportExtensionError(f"{configurationName}.directory: Directory '{directory}' doesn't exist.") from FileNotFoundError(directory) 

188 

189 try: 

190 failBelow = int(packageConfiguration["fail_below"]) / 100 

191 except KeyError as ex: 

192 raise ReportExtensionError(f"{configurationName}.fail_below: Configuration is missing.") from ex 

193 except ValueError as ex: 

194 raise ReportExtensionError(f"{configurationName}.fail_below: '{packageConfiguration['fail_below']}' is not an integer in range 0..100.") from ex 

195 

196 if not (0.0 <= failBelow <= 100.0): 

197 raise ReportExtensionError(f"{configurationName}.fail_below: Is out of range 0..100.") 

198 

199 try: 

200 levels = packageConfiguration["levels"] 

201 except KeyError as ex: 

202 raise ReportExtensionError(f"{configurationName}.levels: Configuration is missing.") from ex 

203 

204 if isinstance(levels, str): 

205 try: 

206 levelDefinition = cls._coverageLevelDefinitions[levels] 

207 except KeyError as ex: 

208 raise ReportExtensionError(f"{configurationName}.levels: Referenced coverage levels '{levels}' are not defined in conf.py variable '{variableName}'.") from ex 

209 elif isinstance(levels, dict): 

210 if 100 not in packageConfiguration["levels"]: 

211 raise ReportExtensionError(f"{configurationName}.levels[100]: Configuration is missing.") 

212 elif "error" not in packageConfiguration["levels"]: 

213 raise ReportExtensionError(f"{configurationName}.levels[error]: Configuration is missing.") 

214 

215 levelDefinition = {} 

216 for x, y in packageConfiguration["levels"].items(): 

217 pass 

218 else: 

219 raise ReportExtensionError(f"") 

220 

221 cls._packageConfigurations[reportID] = { 

222 "name": packageName, 

223 "directory": directory, 

224 "fail_below": failBelow, 

225 "levels": levelDefinition 

226 } 

227 

228 def _ConvertToColor(self, currentLevel: float, configKey: str) -> str: 

229 if currentLevel < 0.0: 

230 return self._levels["error"][configKey] 

231 

232 for levelLimit, levelConfig in self._levels.items(): 

233 if isinstance(levelLimit, int) and (currentLevel * 100) < levelLimit: 

234 return levelConfig[configKey] 

235 

236 return self._levels[100][configKey] 

237 

238 

239@export 

240class DocCoverage(DocCoverageBase): 

241 """ 

242 This directive will be replaced by a table representing documentation coverage. 

243 """ 

244 directiveName: str = "docstr-coverage" 

245 

246 has_content = False 

247 required_arguments = 0 

248 optional_arguments = DocCoverageBase.optional_arguments + 0 

249 

250 option_spec = DocCoverageBase.option_spec 

251 

252 _packageName: str 

253 _directory: Path 

254 _failBelow: float 

255 _coverage: PackageCoverage 

256 

257 def _CheckOptions(self) -> None: 

258 """ 

259 Parse all directive options or use default values. 

260 """ 

261 super()._CheckOptions() 

262 

263 packageConfiguration = self._packageConfigurations[self._reportID] 

264 self._packageName = packageConfiguration["name"] 

265 self._directory = packageConfiguration["directory"] 

266 self._failBelow = packageConfiguration["fail_below"] 

267 self._levels = packageConfiguration["levels"] 

268 

269 def _GenerateCoverageTable(self) -> nodes.table: 

270 cssClasses = ["report-doccov-table", f"report-doccov-{self._reportID}"] 

271 cssClasses.extend(self._cssClasses) 

272 

273 # Create a table and table header with 5 columns 

274 table, tableGroup = self._CreateTableHeader( 

275 identifier=self._reportID, 

276 columns=[ 

277 ("Filename", None, 500), 

278 ("Total", None, 100), 

279 ("Covered", None, 100), 

280 ("Missing", None, 100), 

281 ("Coverage in %", None, 100) 

282 ], 

283 classes=cssClasses 

284 ) 

285 tableBody = nodes.tbody() 

286 tableGroup += tableBody 

287 

288 self._renderlevel(tableBody, self._coverage) 

289 

290 # Add a summary row 

291 tableBody += nodes.row( 

292 "", 

293 nodes.entry("", nodes.paragraph(text=f"Overall ({self._coverage.FileCount} files):")), 

294 nodes.entry("", nodes.paragraph(text=f"{self._coverage.AggregatedExpected}")), 

295 nodes.entry("", nodes.paragraph(text=f"{self._coverage.AggregatedCovered}")), 

296 nodes.entry("", nodes.paragraph(text=f"{self._coverage.AggregatedUncovered}")), 

297 nodes.entry("", nodes.paragraph(text=f"{self._coverage.AggregatedCoverage:.1%}"), 

298 # classes=[self._ConvertToColor(self._coverage.coverage(), "class")] 

299 ), 

300 classes=[ 

301 "report-summary", 

302 self._ConvertToColor(self._coverage.AggregatedCoverage, "class") 

303 ] 

304 ) 

305 

306 return table 

307 

308 def _sortedValues(self, d: Mapping[str, AggregatedCoverage]) -> Generator[AggregatedCoverage, None, None]: 

309 for key in sorted(d.keys()): 

310 yield d[key] 

311 

312 def _renderlevel(self, tableBody: nodes.tbody, packageCoverage: PackageCoverage, level: int = 0) -> None: 

313 tableBody += nodes.row( 

314 "", 

315 nodes.entry("", nodes.paragraph(text=f"{' '*level}📦{packageCoverage.Name}")), 

316 nodes.entry("", nodes.paragraph(text=f"{packageCoverage.Expected}")), 

317 nodes.entry("", nodes.paragraph(text=f"{packageCoverage.Covered}")), 

318 nodes.entry("", nodes.paragraph(text=f"{packageCoverage.Uncovered}")), 

319 nodes.entry("", nodes.paragraph(text=f"{packageCoverage.Coverage:.1%}")), 

320 classes=[ 

321 "report-package", 

322 self._ConvertToColor(packageCoverage.Coverage, "class") 

323 ], 

324 ) 

325 

326 for package in self._sortedValues(packageCoverage._packages): 

327 self._renderlevel(tableBody, package, level + 1) 

328 

329 for module in self._sortedValues(packageCoverage._modules): 

330 tableBody += nodes.row( 

331 "", 

332 nodes.entry("", nodes.paragraph(text=f"{' '*(level+1)} 📓{module.Name}")), 

333 nodes.entry("", nodes.paragraph(text=f"{module.Expected}")), 

334 nodes.entry("", nodes.paragraph(text=f"{module.Covered}")), 

335 nodes.entry("", nodes.paragraph(text=f"{module.Uncovered}")), 

336 nodes.entry("", nodes.paragraph(text=f"{module.Coverage :.1%}")), 

337 classes=[ 

338 "report-module", 

339 self._ConvertToColor(module.Coverage, "class") 

340 ], 

341 ) 

342 

343 

344@export 

345class DocStrCoverage(DocCoverage): 

346 def run(self) -> List[nodes.Node]: 

347 container = nodes.container() 

348 

349 try: 

350 self._CheckOptions() 

351 except ReportExtensionError as ex: 

352 message = f"Caught {ex.__class__.__name__} when checking options for directive '{self.directiveName}'." 

353 return self._internalError(container, __name__, message, ex) 

354 

355 # Assemble a list of Python source files 

356 docStrCov = DocStrCovAnalyzer(self._packageName, self._directory) 

357 docStrCov.Analyze() 

358 self._coverage = docStrCov.Convert() 

359 # self._coverage.CalculateCoverage() 

360 self._coverage.Aggregate() 

361 

362 container += self._GenerateCoverageTable() 

363 

364 return [container] 

365 

366 

367@export 

368class DocCoverageLegend(DocCoverageBase): 

369 """ 

370 This directive will be replaced by a legend table representing coverage levels. 

371 """ 

372 has_content = False 

373 required_arguments = 0 

374 optional_arguments = DocCoverageBase.optional_arguments + 1 

375 

376 option_spec = DocCoverageBase.option_spec | { 

377 "style": strip 

378 } 

379 

380 directiveName: str = "doc-coverage-legend" 

381 

382 _style: LegendStyle 

383 

384 def _CheckOptions(self) -> None: 

385 # Parse all directive options or use default values 

386 super()._CheckOptions() 

387 

388 self._style = self._ParseLegendStyle("style", LegendStyle.horizontal_table) 

389 

390 packageConfiguration = self._packageConfigurations[self._reportID] 

391 self._levels = packageConfiguration["levels"] 

392 

393 def _CreateHorizontalLegendTable(self, identifier: str, classes: List[str]) -> nodes.table: 

394 columns = [("Documentation Coverage:", None, 300)] 

395 for level in self._levels: 

396 if isinstance(level, int): 

397 columns.append((f"{level} %", None, 200)) 

398 

399 table, tableGroup = self._CreateTableHeader(columns, identifier=identifier, classes=classes) 

400 tableBody = nodes.tbody() 

401 tableGroup += tableBody 

402 

403 legendRow = nodes.row("", classes=["report-doccov-legend-row"]) 

404 legendRow += nodes.entry("", nodes.paragraph(text="Coverage Level:")) 

405 tableBody += legendRow 

406 for level, config in self._levels.items(): 

407 if isinstance(level, int): 

408 legendRow += nodes.entry("", nodes.paragraph(text=config["desc"]), classes=[self._ConvertToColor((level - 1) / 100, "class")]) 

409 

410 return table 

411 

412 def _CreateVerticalLegendTable(self, identifier: str, classes: List[str]) -> nodes.table: 

413 table, tableGroup = self._CreateTableHeader([ 

414 ("Documentation Coverage", None, 300), 

415 ("Coverage Level", None, 300) 

416 ], 

417 identifier=identifier, 

418 classes=classes 

419 ) 

420 

421 tableBody = nodes.tbody() 

422 tableGroup += tableBody 

423 

424 for level, config in self._levels.items(): 

425 if isinstance(level, int): 

426 tableBody += nodes.row( 

427 "", 

428 nodes.entry("", nodes.paragraph(text=f"{level} %")), 

429 nodes.entry("", nodes.paragraph(text=config["desc"])), 

430 classes=["report-doccov-legend-row", self._ConvertToColor((level - 1) / 100, "class")] 

431 ) 

432 

433 return table 

434 

435 def run(self) -> List[nodes.Node]: 

436 container = nodes.container() 

437 

438 try: 

439 self._CheckOptions() 

440 except ReportExtensionError as ex: 

441 message = f"Caught {ex.__class__.__name__} when checking options for directive '{self.directiveName}'." 

442 return self._internalError(container, __name__, message, ex) 

443 

444 if LegendStyle.Table in self._style: 

445 if LegendStyle.Horizontal in self._style: 

446 container += self._CreateHorizontalLegendTable(identifier=f"{self._reportID}-legend", classes=["report-doccov-legend"]) 

447 elif LegendStyle.Vertical in self._style: 

448 container += self._CreateVerticalLegendTable(identifier=f"{self._reportID}-legend", classes=["report-doccov-legend"]) 

449 else: 

450 container += nodes.paragraph(text=f"Unsupported legend style.") 

451 else: 

452 container += nodes.paragraph(text=f"Unsupported legend style.") 

453 

454 return [container]