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
« 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
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
44from sphinx_reports.Common import ReportExtensionError, LegendStyle
45from sphinx_reports.Sphinx import strip, BaseDirective
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]]]
55@export
56class DocCoverageBase(BaseDirective):
57 option_spec = {
58 "class": strip,
59 "reportid": strip,
60 }
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 }
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.
86 _coverageLevelDefinitions: ClassVar[Dict[str, Dict[Union[int, str], Dict[str, str]]]] = {}
87 _packageConfigurations: ClassVar[Dict[str, package_DictType]] = {}
89 _cssClasses: List[str]
90 _reportID: str
91 _levels: Dict[Union[int, str], Dict[str, str]]
93 def _CheckOptions(self) -> None:
94 """
95 Parse all directive options or use default values.
96 """
97 cssClasses = self._ParseStringOption("class", "", r"(\w+)?( +\w+)*")
99 self._reportID = self._ParseStringOption("reportid")
100 self._cssClasses = [] if cssClasses == "" else cssClasses.split(" ")
102 @classmethod
103 def CheckConfiguration(cls, sphinxApplication: Sphinx, sphinxConfiguration: Config) -> None:
104 """
105 Check configuration fields and load necessary values.
107 :param sphinxApplication: Sphinx application instance.
108 :param sphinxConfiguration: Sphinx configuration instance.
109 """
110 cls._CheckLevelsConfiguration(sphinxConfiguration)
111 cls._CheckPackagesConfiguration(sphinxConfiguration)
113 @classmethod
114 def _CheckLevelsConfiguration(cls, sphinxConfiguration: Config) -> None:
115 from sphinx_reports import ReportDomain
117 variableName = f"{ReportDomain.name}_{cls.configPrefix}_levels"
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
124 if "default" not in coverageLevelDefinitions:
125 cls._coverageLevelDefinitions["default"] = cls.defaultCoverageDefinitions["default"]
127 for key, coverageLevelDefinition in coverageLevelDefinitions.items():
128 configurationName = f"conf.py: {variableName}:[{key}]"
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.")
135 cls._coverageLevelDefinitions[key] = {}
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
147 try:
148 cssClass = levelConfig["class"]
149 except KeyError as ex:
150 raise ReportExtensionError(f"{configurationName}[{level}].class: CSS class is missing.") from ex
152 try:
153 description = levelConfig["desc"]
154 except KeyError as ex:
155 raise ReportExtensionError(f"{configurationName}[{level}].desc: Description is missing.") from ex
157 cls._coverageLevelDefinitions[key][level] = {
158 "class": cssClass,
159 "desc": description
160 }
162 @classmethod
163 def _CheckPackagesConfiguration(cls, sphinxConfiguration: Config) -> None:
164 from sphinx_reports import ReportDomain
166 variableName = f"{ReportDomain.name}_{cls.configPrefix}_packages"
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
173 for reportID, packageConfiguration in allPackages.items():
174 configurationName = f"conf.py: {variableName}:[{reportID}]"
176 try:
177 packageName = packageConfiguration["name"]
178 except KeyError as ex:
179 raise ReportExtensionError(f"{configurationName}.name: Configuration is missing.") from ex
181 try:
182 directory = Path(packageConfiguration["directory"])
183 except KeyError as ex:
184 raise ReportExtensionError(f"{configurationName}.directory: Configuration is missing.") from ex
186 if not directory.exists():
187 raise ReportExtensionError(f"{configurationName}.directory: Directory '{directory}' doesn't exist.") from FileNotFoundError(directory)
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
196 if not (0.0 <= failBelow <= 100.0):
197 raise ReportExtensionError(f"{configurationName}.fail_below: Is out of range 0..100.")
199 try:
200 levels = packageConfiguration["levels"]
201 except KeyError as ex:
202 raise ReportExtensionError(f"{configurationName}.levels: Configuration is missing.") from ex
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.")
215 levelDefinition = {}
216 for x, y in packageConfiguration["levels"].items():
217 pass
218 else:
219 raise ReportExtensionError(f"")
221 cls._packageConfigurations[reportID] = {
222 "name": packageName,
223 "directory": directory,
224 "fail_below": failBelow,
225 "levels": levelDefinition
226 }
228 def _ConvertToColor(self, currentLevel: float, configKey: str) -> str:
229 if currentLevel < 0.0:
230 return self._levels["error"][configKey]
232 for levelLimit, levelConfig in self._levels.items():
233 if isinstance(levelLimit, int) and (currentLevel * 100) < levelLimit:
234 return levelConfig[configKey]
236 return self._levels[100][configKey]
239@export
240class DocCoverage(DocCoverageBase):
241 """
242 This directive will be replaced by a table representing documentation coverage.
243 """
244 directiveName: str = "docstr-coverage"
246 has_content = False
247 required_arguments = 0
248 optional_arguments = DocCoverageBase.optional_arguments + 0
250 option_spec = DocCoverageBase.option_spec
252 _packageName: str
253 _directory: Path
254 _failBelow: float
255 _coverage: PackageCoverage
257 def _CheckOptions(self) -> None:
258 """
259 Parse all directive options or use default values.
260 """
261 super()._CheckOptions()
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"]
269 def _GenerateCoverageTable(self) -> nodes.table:
270 cssClasses = ["report-doccov-table", f"report-doccov-{self._reportID}"]
271 cssClasses.extend(self._cssClasses)
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
288 self._renderlevel(tableBody, self._coverage)
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 )
306 return table
308 def _sortedValues(self, d: Mapping[str, AggregatedCoverage]) -> Generator[AggregatedCoverage, None, None]:
309 for key in sorted(d.keys()):
310 yield d[key]
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 )
326 for package in self._sortedValues(packageCoverage._packages):
327 self._renderlevel(tableBody, package, level + 1)
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 )
344@export
345class DocStrCoverage(DocCoverage):
346 def run(self) -> List[nodes.Node]:
347 container = nodes.container()
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)
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()
362 container += self._GenerateCoverageTable()
364 return [container]
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
376 option_spec = DocCoverageBase.option_spec | {
377 "style": strip
378 }
380 directiveName: str = "doc-coverage-legend"
382 _style: LegendStyle
384 def _CheckOptions(self) -> None:
385 # Parse all directive options or use default values
386 super()._CheckOptions()
388 self._style = self._ParseLegendStyle("style", LegendStyle.horizontal_table)
390 packageConfiguration = self._packageConfigurations[self._reportID]
391 self._levels = packageConfiguration["levels"]
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))
399 table, tableGroup = self._CreateTableHeader(columns, identifier=identifier, classes=classes)
400 tableBody = nodes.tbody()
401 tableGroup += tableBody
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")])
410 return table
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 )
421 tableBody = nodes.tbody()
422 tableGroup += tableBody
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 )
433 return table
435 def run(self) -> List[nodes.Node]:
436 container = nodes.container()
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)
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.")
454 return [container]