# ==================================================================================================================== #
# _ _ _ #
# ___ _ __ | |__ (_)_ __ __ __ _ __ ___ _ __ ___ _ __| |_ ___ #
# / __| '_ \| '_ \| | '_ \\ \/ /____| '__/ _ \ '_ \ / _ \| '__| __/ __| #
# \__ \ |_) | | | | | | | |> <_____| | | __/ |_) | (_) | | | |_\__ \ #
# |___/ .__/|_| |_|_|_| |_/_/\_\ |_| \___| .__/ \___/|_| \__|___/ #
# |_| |_| #
# ==================================================================================================================== #
# Authors: #
# Patrick Lehmann #
# #
# License: #
# ==================================================================================================================== #
# Copyright 2023-2024 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, Iterable, 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, AggregatedCoverage, 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 = {
"packageid": strip
}
defaultCoverageDefinitions = {
"default": {
30: {"class": "report-cov-below30", "desc": "almost unused"},
50: {"class": "report-cov-below50", "desc": "poorly used"},
80: {"class": "report-cov-below80", "desc": "somehow used"},
90: {"class": "report-cov-below90", "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]] = {}
_packageID: str
_levels: Dict[Union[int, str], Dict[str, str]]
def _CheckOptions(self) -> None:
# Parse all directive options or use default values
self._packageID = self._ParseStringOption("packageid")
[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 = 2
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:
# Create a table and table header with 10 columns
columns = [
("Package", [(" Module", 500)], None),
("Statments", [("Total", 100), ("Excluded", 100), ("Covered", 100), ("Missing", 100)], None),
("Branches" , [("Total", 100), ("Covered", 100), ("Partial", 100), ("Missing", 100)], None),
("Coverage", [("in %", 100)], None)
]
if self._noBranchCoverage:
columns.pop(2)
table, tableGroup = self._CreateTableHeader(
identifier=self._packageID,
columns=columns,
classes=["report-codecov-table"]
)
tableBody = nodes.tbody()
tableGroup += tableBody
def sortedValues(d: Mapping[str, AggregatedCoverage]) -> Generator[AggregatedCoverage, None, None]:
for key in sorted(d.keys()):
yield d[key]
def renderlevel(tableBody: nodes.tbody, packageCoverage: PackageCoverage, level: int = 0) -> None:
tableRow = nodes.row("", classes=[
"report-codecov-table-row",
"report-codecov-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}"))
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.Coverage:.1%}"))
for package in sortedValues(packageCoverage._packages):
renderlevel(tableBody, package, level + 1)
for module in sortedValues(packageCoverage._modules):
tableRow = nodes.row("", classes=[
"report-codecov-table-row",
"report-codecov-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}"))
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.Coverage :.1%}"))
renderlevel(tableBody, self._coverage)
# Add a summary row
tableRow = nodes.row("", classes=[
"report-codecov-table-row",
"report-codecov-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.AggregatedExpected}")),
tableRow += nodes.entry("", nodes.paragraph(text=f"")) # {self._coverage.AggregatedCovered}")),
tableRow += nodes.entry("", nodes.paragraph(text=f"")) # {self._coverage.AggregatedCovered}")),
tableRow += nodes.entry("", nodes.paragraph(text=f"")) # {self._coverage.AggregatedCovered}")),
if not self._noBranchCoverage:
tableRow += nodes.entry("", nodes.paragraph(text=f"")) # {self._coverage.AggregatedCovered}")),
tableRow += nodes.entry("", nodes.paragraph(text=f"")) # {self._coverage.AggregatedCovered}")),
tableRow += nodes.entry("", nodes.paragraph(text=f"")) # {self._coverage.AggregatedCovered}")),
tableRow += nodes.entry("", nodes.paragraph(text=f"")) # {self._coverage.AggregatedUncovered}")),
tableRow += nodes.entry("", nodes.paragraph(text=f"")) # {self._coverage.AggregatedCoverage:.1%}")),
return table
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()
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 = 2
option_spec = CodeCoverageBase.option_spec | {
"style": strip
}
directiveName: str = "code-coverage-legend"
_style: LegendStyle
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]