Source code for sphinx_reports.CodeCoverage

# ==================================================================================================================== #
#            _     _                                           _                                                       #
#  ___ _ __ | |__ (_)_ __ __  __     _ __ ___ _ __   ___  _ __| |_ ___                                                 #
# / __| '_ \| '_ \| | '_ \\ \/ /____| '__/ _ \ '_ \ / _ \| '__| __/ __|                                                #
# \__ \ |_) | | | | | | | |>  <_____| | |  __/ |_) | (_) | |  | |_\__ \                                                #
# |___/ .__/|_| |_|_|_| |_/_/\_\    |_|  \___| .__/ \___/|_|   \__|___/                                                #
#     |_|                                    |_|                                                                       #
# ==================================================================================================================== #
# Authors:                                                                                                             #
#   Patrick Lehmann                                                                                                    #
#                                                                                                                      #
# License:                                                                                                             #
# ==================================================================================================================== #
# Copyright 2023-2025 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                                                                                  #
# ==================================================================================================================== #
#
"""
**Report code coverage as Sphinx documentation page(s).**
"""
from pathlib import Path
from typing  import Dict, Tuple, Any, List, Mapping, Generator, TypedDict, Union, Optional as Nullable, ClassVar

from docutils                              import nodes
from docutils.parsers.rst.directives       import flag
from sphinx.addnodes                       import toctree
from sphinx.application                    import Sphinx
from sphinx.config                         import Config
from sphinx.directives.code                import LiteralIncludeReader
from sphinx.util.docutils                  import new_document
from pyTooling.Decorators                  import export

from sphinx_reports.Common                 import ReportExtensionError, LegendStyle
from sphinx_reports.Sphinx                 import strip, BaseDirective
from sphinx_reports.DataModel.CodeCoverage import PackageCoverage, Coverage, ModuleCoverage
from sphinx_reports.Adapter.Coverage       import Analyzer


class package_DictType(TypedDict):
	name:        str
	json_report: Path
	fail_below:  int
	levels:      Union[str, Dict[Union[int, str], Dict[str, str]]]


[docs] @export class CodeCoverageBase(BaseDirective): option_spec = { "class": strip, "packageid": strip } defaultCoverageDefinitions = { "default": { 10: {"class": "report-cov-below10", "desc": "almost unused"}, 20: {"class": "report-cov-below20", "desc": "almost unused"}, 30: {"class": "report-cov-below30", "desc": "almost unused"}, 40: {"class": "report-cov-below40", "desc": "poorly used"}, 50: {"class": "report-cov-below50", "desc": "poorly used"}, 60: {"class": "report-cov-below60", "desc": "somehow used"}, 70: {"class": "report-cov-below70", "desc": "somehow used"}, 80: {"class": "report-cov-below80", "desc": "somehow used"}, 85: {"class": "report-cov-below85", "desc": "well used"}, 90: {"class": "report-cov-below90", "desc": "well used"}, 95: {"class": "report-cov-below95", "desc": "well used"}, 100: {"class": "report-cov-below100", "desc": "excellently used"}, "error": {"class": "report-cov-error", "desc": "internal error"}, } } configPrefix: str = "codecov" configValues: Dict[str, Tuple[Any, str, Any]] = { f"{configPrefix}_packages": ({}, "env", Dict), f"{configPrefix}_levels": (defaultCoverageDefinitions, "env", Dict), } #: A dictionary of all configuration values used by code coverage directives. _coverageLevelDefinitions: ClassVar[Dict[str, Dict[Union[int, str], Dict[str, str]]]] = {} _packageConfigurations: ClassVar[Dict[str, package_DictType]] = {} _cssClasses: List[str] _packageID: str _levels: Dict[Union[int, str], Dict[str, str]]
[docs] def _CheckOptions(self) -> None: """ Parse all directive options or use default values. """ cssClasses = self._ParseStringOption("class", "", r"(\w+)?( +\w+)*") self._packageID = self._ParseStringOption("packageid") self._cssClasses = [] if cssClasses == "" else cssClasses.split(" ")
[docs] @classmethod def CheckConfiguration(cls, sphinxApplication: Sphinx, sphinxConfiguration: Config) -> None: """ Check configuration fields and load necessary values. :param sphinxApplication: Sphinx application instance. :param sphinxConfiguration: Sphinx configuration instance. """ cls._CheckLevelsConfiguration(sphinxConfiguration) cls._CheckPackagesConfiguration(sphinxConfiguration)
[docs] @classmethod def ReadReports(cls, sphinxApplication: Sphinx) -> None: """ Read code coverage report files. :param sphinxApplication: Sphinx application instance. """ print(f"[REPORT] Reading code coverage reports ...")
@classmethod def _CheckLevelsConfiguration(cls, sphinxConfiguration: Config) -> None: from sphinx_reports import ReportDomain variableName = f"{ReportDomain.name}_{cls.configPrefix}_levels" try: coverageLevelDefinitions: Dict[str, package_DictType] = sphinxConfiguration[f"{ReportDomain.name}_{cls.configPrefix}_levels"] except (KeyError, AttributeError) as ex: raise ReportExtensionError(f"Configuration option '{variableName}' is not configured.") from ex if "default" not in coverageLevelDefinitions: cls._coverageLevelDefinitions["default"] = cls.defaultCoverageDefinitions["default"] for key, coverageLevelDefinition in coverageLevelDefinitions.items(): configurationName = f"conf.py: {variableName}:[{key}]" if 100 not in coverageLevelDefinition: raise ReportExtensionError(f"{configurationName}[100]: Configuration is missing.") elif "error" not in coverageLevelDefinition: raise ReportExtensionError(f"{configurationName}[error]: Configuration is missing.") cls._coverageLevelDefinitions[key] = {} for level, levelConfig in coverageLevelDefinition.items(): try: if isinstance(level, str): if level != "error": raise ReportExtensionError(f"{configurationName}[{level}]: Level is a keyword, but not 'error'.") elif not (0.0 <= int(level) <= 100.0): raise ReportExtensionError(f"{configurationName}[{level}]: Level is out of range 0..100.") except ValueError as ex: raise ReportExtensionError(f"{configurationName}[{level}]: Level is not a keyword or an integer in range 0..100.") from ex try: cssClass = levelConfig["class"] except KeyError as ex: raise ReportExtensionError(f"{configurationName}[{level}].class: CSS class is missing.") from ex try: description = levelConfig["desc"] except KeyError as ex: raise ReportExtensionError(f"{configurationName}[{level}].desc: Description is missing.") from ex cls._coverageLevelDefinitions[key][level] = { "class": cssClass, "desc": description } @classmethod def _CheckPackagesConfiguration(cls, sphinxConfiguration: Config) -> None: from sphinx_reports import ReportDomain variableName = f"{ReportDomain.name}_{cls.configPrefix}_packages" try: allPackages: Dict[str, package_DictType] = sphinxConfiguration[f"{ReportDomain.name}_{cls.configPrefix}_packages"] except (KeyError, AttributeError) as ex: raise ReportExtensionError(f"Configuration option '{variableName}' is not configured.") from ex # try: # packageConfiguration = allPackages[self._packageID] # except KeyError as ex: # raise ReportExtensionError(f"conf.py: {ReportDomain.name}_{cls.configPrefix}_packages: No configuration found for '{self._packageID}'.") from ex for packageID, packageConfiguration in allPackages.items(): configurationName = f"conf.py: {variableName}:[{packageID}]" try: packageName = packageConfiguration["name"] except KeyError as ex: raise ReportExtensionError(f"{configurationName}.name: Configuration is missing.") from ex try: jsonReport = Path(packageConfiguration["json_report"]) except KeyError as ex: raise ReportExtensionError(f"{configurationName}.json_report: Configuration is missing.") from ex if not jsonReport.exists(): raise ReportExtensionError(f"{configurationName}.json_report: Coverage report file '{jsonReport}' doesn't exist.") from FileNotFoundError(jsonReport) try: failBelow = int(packageConfiguration["fail_below"]) / 100 except KeyError as ex: raise ReportExtensionError(f"{configurationName}.fail_below: Configuration is missing.") from ex except ValueError as ex: raise ReportExtensionError(f"{configurationName}.fail_below: '{packageConfiguration['fail_below']}' is not an integer in range 0..100.") from ex if not (0.0 <= failBelow <= 100.0): raise ReportExtensionError(f"{configurationName}.fail_below: Is out of range 0..100.") try: levels = packageConfiguration["levels"] except KeyError as ex: raise ReportExtensionError(f"{configurationName}.levels: Configuration is missing.") from ex if isinstance(levels, str): try: levelDefinition = cls._coverageLevelDefinitions[levels] except KeyError as ex: raise ReportExtensionError( f"{configurationName}.levels: Referenced coverage levels '{levels}' are not defined in conf.py variable '{variableName}'.") from ex elif isinstance(levels, dict): if 100 not in packageConfiguration["levels"]: raise ReportExtensionError(f"{configurationName}.levels[100]: Configuration is missing.") elif "error" not in packageConfiguration["levels"]: raise ReportExtensionError(f"{configurationName}.levels[error]: Configuration is missing.") levelDefinition = {} for x, y in packageConfiguration["levels"].items(): pass else: raise ReportExtensionError(f"") cls._packageConfigurations[packageID] = { "name": packageName, "json_report": jsonReport, "fail_below": failBelow, "levels": levelDefinition } def _ConvertToColor(self, currentLevel: float, configKey: str) -> str: if currentLevel < 0.0: return self._levels["error"][configKey] for levelLimit, levelConfig in self._levels.items(): if isinstance(levelLimit, int) and (currentLevel * 100) < levelLimit: return levelConfig[configKey] return self._levels[100][configKey]
[docs] @export class CodeCoverage(CodeCoverageBase): """ This directive will be replaced by a table representing code coverage. """ directiveName: str = "code-coverage" has_content = False required_arguments = 0 optional_arguments = CodeCoverageBase.optional_arguments + 1 option_spec = CodeCoverageBase.option_spec | { "no-branch-coverage": flag } _noBranchCoverage: bool _packageName: str _jsonReport: Path _failBelow: float _coverage: PackageCoverage
[docs] def _CheckOptions(self) -> None: """ Parse all directive options or use default values. """ super()._CheckOptions() self._noBranchCoverage = "no-branch-coverage" in self.options try: packageConfiguration = self._packageConfigurations[self._packageID] except KeyError as ex: raise ReportExtensionError(f"No configuration for '{self._packageID}'") from ex self._packageName = packageConfiguration["name"] self._jsonReport = packageConfiguration["json_report"] self._failBelow = packageConfiguration["fail_below"] self._levels = packageConfiguration["levels"]
def _GenerateCoverageTable(self) -> nodes.table: cssClasses = ["report-codecov-table", f"report-codecov-{self._packageID}"] cssClasses.extend(self._cssClasses) # Create a table and table header with 10 columns columns = [ ("Package", [(" Module", 500)], None), ("Statments", [("Total", 100), ("Excluded", 100), ("Covered", 100), ("Missing", 100), ("Coverage", 100)], None), ("Branches" , [("Total", 100), ("Covered", 100), ("Partial", 100), ("Missing", 100), ("Coverage", 100)], None), # ("Coverage", [("in %", 100)], None) ] if self._noBranchCoverage: columns.pop(2) table, tableGroup = self._CreateTableHeader( identifier=self._packageID, columns=columns, classes=cssClasses ) tableBody = nodes.tbody() tableGroup += tableBody self.renderlevel(tableBody, self._coverage) # Add a summary row tableRow = nodes.row("", classes=[ "report-summary", self._ConvertToColor(self._coverage.Coverage, "class") ]) tableBody += tableRow tableRow += nodes.entry("", nodes.paragraph(text=f"Overall ({self._coverage.FileCount} files):")) tableRow += nodes.entry("", nodes.paragraph(text=f"{self._coverage.AggregatedTotalStatements}")) tableRow += nodes.entry("", nodes.paragraph(text=f"{self._coverage.AggregatedExcludedStatements}")) tableRow += nodes.entry("", nodes.paragraph(text=f"{self._coverage.AggregatedCoveredStatements}")) tableRow += nodes.entry("", nodes.paragraph(text=f"{self._coverage.AggregatedMissingStatements}")) tableRow += nodes.entry("", nodes.paragraph(text=f"{self._coverage.AggregatedStatementCoverage:.1%}")) if not self._noBranchCoverage: tableRow += nodes.entry("", nodes.paragraph(text=f"{self._coverage.AggregatedTotalBranches}")) tableRow += nodes.entry("", nodes.paragraph(text=f"{self._coverage.AggregatedCoveredBranches}")) tableRow += nodes.entry("", nodes.paragraph(text=f"{self._coverage.AggregatedPartialBranches}")) tableRow += nodes.entry("", nodes.paragraph(text=f"{self._coverage.AggregatedMissingBranches}")) tableRow += nodes.entry("", nodes.paragraph(text=f"{self._coverage.AggregatedBranchCoverage:.1%}")) return table def sortedValues(self, d: Mapping[str, Coverage]) -> Generator[Coverage, None, None]: for key in sorted(d.keys()): yield d[key] def renderlevel(self, tableBody: nodes.tbody, packageCoverage: PackageCoverage, level: int = 0) -> None: tableRow = nodes.row("", classes=[ "report-package", self._ConvertToColor(packageCoverage.Coverage, "class") ]) tableBody += tableRow tableRow += nodes.entry("", nodes.paragraph(text=f"{' ' * level}📦{packageCoverage.Name}")) tableRow += nodes.entry("", nodes.paragraph(text=f"{packageCoverage.TotalStatements}")) tableRow += nodes.entry("", nodes.paragraph(text=f"{packageCoverage.ExcludedStatements}")) tableRow += nodes.entry("", nodes.paragraph(text=f"{packageCoverage.CoveredStatements}")) tableRow += nodes.entry("", nodes.paragraph(text=f"{packageCoverage.MissingStatements}")) tableRow += nodes.entry("", nodes.paragraph(text=f"{packageCoverage.StatementCoverage:.1%}")) if not self._noBranchCoverage: tableRow += nodes.entry("", nodes.paragraph(text=f"{packageCoverage.TotalBranches}")) tableRow += nodes.entry("", nodes.paragraph(text=f"{packageCoverage.CoveredBranches}")) tableRow += nodes.entry("", nodes.paragraph(text=f"{packageCoverage.PartialBranches}")) tableRow += nodes.entry("", nodes.paragraph(text=f"{packageCoverage.MissingBranches}")) tableRow += nodes.entry("", nodes.paragraph(text=f"{packageCoverage.BranchCoverage:.1%}")) for package in self.sortedValues(packageCoverage._packages): self.renderlevel(tableBody, package, level + 1) for module in self.sortedValues(packageCoverage._modules): tableRow = nodes.row("", classes=[ "report-module", self._ConvertToColor(module.Coverage, "class") ]) tableBody += tableRow tableRow += nodes.entry("", nodes.paragraph(text=f"{' ' * (level + 1)}  {module.Name}")) tableRow += nodes.entry("", nodes.paragraph(text=f"{module.TotalStatements}")) tableRow += nodes.entry("", nodes.paragraph(text=f"{module.ExcludedStatements}")) tableRow += nodes.entry("", nodes.paragraph(text=f"{module.CoveredStatements}")) tableRow += nodes.entry("", nodes.paragraph(text=f"{module.MissingStatements}")) tableRow += nodes.entry("", nodes.paragraph(text=f"{module.StatementCoverage:.1%}")) if not self._noBranchCoverage: tableRow += nodes.entry("", nodes.paragraph(text=f"{module.TotalBranches}")) tableRow += nodes.entry("", nodes.paragraph(text=f"{module.CoveredBranches}")) tableRow += nodes.entry("", nodes.paragraph(text=f"{module.PartialBranches}")) tableRow += nodes.entry("", nodes.paragraph(text=f"{module.MissingBranches}")) tableRow += nodes.entry("", nodes.paragraph(text=f"{module.BranchCoverage:.1%}")) def _CreatePages(self) -> None: def handlePackage(package: PackageCoverage) -> None: for pack in package._packages.values(): if handlePackage(pack): return for module in package._modules.values(): if handleModule(module): return def handleModule(module: ModuleCoverage) -> None: doc = new_document("dummy") rootSection = nodes.section(ids=["foo"]) doc += rootSection title = nodes.title(text=f"{module.Name}") rootSection += title rootSection += nodes.paragraph(text="some text") docname = f"coverage/{module.Name}" self.env.titles[docname] = title self.env.longtitles[docname] = title return handlePackage(self._coverage)
[docs] def run(self) -> List[nodes.Node]: container = nodes.container() try: self._CheckOptions() except ReportExtensionError as ex: message = f"Caught {ex.__class__.__name__} when checking options for directive '{self.directiveName}'." return self._internalError(container, __name__, message, ex) # Assemble a list of Python source files analyzer = Analyzer(self._packageName, self._jsonReport) self._coverage = analyzer.Convert() # self._coverage.Aggregate() self._CreatePages() container += self._GenerateCoverageTable() def foo(): docName = self.env.docname docParent = docName[:docName.rindex("/")] subnode = toctree() subnode['parent'] = docName # (title, ref) pairs, where ref may be a document, or an external link, # and title may be None if the document's title is to be used subnode['entries'] = [] subnode['includefiles'] = [] subnode['maxdepth'] = -1 # self.options.get('maxdepth', -1) subnode['caption'] = None # self.options.get('caption') subnode['glob'] = None # 'glob' in self.options subnode['hidden'] = True # 'hidden' in self.options subnode['includehidden'] = False # 'includehidden' in self.options subnode['numbered'] = 0 # self.options.get('numbered', 0) subnode['titlesonly'] = False # 'titlesonly' in self.options self.set_source_info(subnode) wrappernode = nodes.compound(classes=['toctree-wrapper']) wrappernode.append(subnode) self.add_name(wrappernode) for entry in ( "sphinx_reports", "sphinx_reports.Adapter", "sphinx_reports.Adapter.Coverage", "sphinx_reports.Adapter.DocStrCoverage", "sphinx_reports.Adapter.JUnit", "sphinx_reports.DataModel", "sphinx_reports.DataModel.CodeCoverage", "sphinx_reports.DataModel.DocumentationCoverage", "sphinx_reports.DataModel.Unittest", "sphinx_reports.static", "sphinx_reports.CodeCoverage", "sphinx_reports.Common", "sphinx_reports.DocCoverage", "sphinx_reports.Sphinx", "sphinx_reports.Unittest", ): moduleDocumentName = f"{docParent}/{entry}" moduleDocumentTitle = entry subnode["entries"].append((moduleDocumentTitle, moduleDocumentName)) subnode["includefiles"].append(moduleDocumentName) return [container] #, wrappernode]
[docs] @export class CodeCoverageLegend(CodeCoverageBase): """ This directive will be replaced by a legend table representing coverage levels. """ has_content = False required_arguments = 0 optional_arguments = CodeCoverageBase.optional_arguments + 1 option_spec = CodeCoverageBase.option_spec | { "style": strip } directiveName: str = "code-coverage-legend" _style: LegendStyle
[docs] def _CheckOptions(self) -> None: # Parse all directive options or use default values super()._CheckOptions() self._style = self._ParseLegendStyle("style", LegendStyle.horizontal_table) try: packageConfiguration = self._packageConfigurations[self._packageID] except KeyError as ex: raise ReportExtensionError(f"No configuration for '{self._packageID}'") from ex self._levels = packageConfiguration["levels"]
def _CreateHorizontalLegendTable(self, identifier: str, classes: List[str]) -> nodes.table: columns = [("Code Coverage:", None, 300)] for level in self._levels: if isinstance(level, int): columns.append((f"≤{level} %", None, 200)) table, tableGroup = self._CreateTableHeader(columns, identifier=identifier, classes=classes) tableBody = nodes.tbody() tableGroup += tableBody legendRow = nodes.row("", classes=["report-codecov-legend-row"]) legendRow += nodes.entry("", nodes.paragraph(text="Coverage Level:")) tableBody += legendRow for level, config in self._levels.items(): if isinstance(level, int): legendRow += nodes.entry("", nodes.paragraph(text=config["desc"]), classes=[self._ConvertToColor((level - 1) / 100, "class")]) legendRow = nodes.row("", classes=["report-codecov-legend-row"]) legendRow += nodes.entry("", nodes.paragraph(text="Coverage Level:")) tableBody += legendRow for level, config in self._levels.items(): if isinstance(level, int): legendRow += nodes.entry("", nodes.paragraph(text=config["desc"]), classes=[self._ConvertToColor((level - 1) / 100, "class")]) return table def _CreateVerticalLegendTable(self, identifier: str, classes: List[str]) -> nodes.table: table, tableGroup = self._CreateTableHeader([ ("Code Coverage", None, 300), ("Coverage Level", None, 300) ], identifier=identifier, classes=classes ) tableBody = nodes.tbody() tableGroup += tableBody for level, config in self._levels.items(): if isinstance(level, int): tableBody += nodes.row( "", nodes.entry("", nodes.paragraph(text=f"≤{level} %")), nodes.entry("", nodes.paragraph(text=config["desc"])), classes=["report-codecov-legend-row", self._ConvertToColor((level - 1) / 100, "class")] ) return table
[docs] def run(self) -> List[nodes.Node]: container = nodes.container() try: self._CheckOptions() except ReportExtensionError as ex: message = f"Caught {ex.__class__.__name__} when checking options for directive '{self.directiveName}'." return self._internalError(container, __name__, message, ex) if LegendStyle.Table in self._style: if LegendStyle.Horizontal in self._style: container += self._CreateHorizontalLegendTable(identifier=f"{self._packageID}-legend", classes=["report-codecov-legend"]) elif LegendStyle.Vertical in self._style: container += self._CreateVerticalLegendTable(identifier=f"{self._packageID}-legend", classes=["report-codecov-legend"]) else: container += nodes.paragraph(text=f"Unsupported legend style.") else: container += nodes.paragraph(text=f"Unsupported legend style.") return [container]
[docs] @export class ModuleCoverage(CodeCoverageBase): """ This directive will be replaced by highlighted source code. """ directiveName: str = "module-coverage" has_content = False required_arguments = 0 optional_arguments = 2 option_spec = CodeCoverageBase.option_spec | { "module": strip } _packageName: str _moduleName: str _jsonReport: Path
[docs] def _CheckOptions(self) -> None: """ Parse all directive options or use default values. """ super()._CheckOptions() self._moduleName = self._ParseStringOption("module") try: packageConfiguration = self._packageConfigurations[self._packageID] except KeyError as ex: raise ReportExtensionError(f"No configuration for '{self._packageID}'") from ex self._packageName = packageConfiguration["name"] self._jsonReport = packageConfiguration["json_report"]
[docs] def run(self) -> List[nodes.Node]: container = nodes.container() try: self._CheckOptions() except ReportExtensionError as ex: message = f"Caught {ex.__class__.__name__} when checking options for directive '{self.directiveName}'." return self._internalError(container, __name__, message, ex) # Assemble a list of Python source files analyzer = Analyzer(self._packageName, self._jsonReport) self._coverage = analyzer.Convert() sourceFile = "../../sphinx_reports/__init__.py" container += nodes.paragraph(text=f"Code coverage of {self._moduleName}") # lexer = get_lexer_by_name("python", tabsize=2) # tokens = lex(code, lexer) # htmlFormatter = HtmlFormatter(linenos=True, cssclass="source") # highlight() location = self.state_machine.get_source_and_line(self.lineno) rel_filename, filename = self.env.relfn2path(sourceFile) self.env.note_dependency(rel_filename) reader = LiteralIncludeReader(filename, {"tab-width": 2}, self.config) text, lines = reader.read(location=location) literalBlock: nodes.Element = nodes.literal_block(text, text, source=filename) literalBlock["language"] = "codecov" literalBlock['highlight_args'] = extra_args = {} extra_args['hl_lines'] = [i for i in range(10, 20)] self.set_source_info(literalBlock) container += literalBlock return [container]