CodeCoverage.py

Summary

Lines:

302 of 360 covered (83.9%)

Excluded lines:

2

Branches:

70 of 88 taken (79.5%), 10 lines partially

Coverage:

83.0%

Directory:

Sphinx

Missed lines:

241-242, 274-275, 288-290, 306-307, 328-329, 358, 365, 367-369, 380-381, 417-418, 455-458, 463, 467-468, 575, 679-681, 685-688, 730-731, 743-761, 803-805, 808, 816, 855-856

Source

pyTooling/Sphinx/CodeCoverage.pycoveredpartialuncoveredexcludednot executable
1# ==================================================================================================================== #
2#             _____           _ _               ____        _     _                                                    #
3#  _ __  _   |_   _|__   ___ | (_)_ __   __ _  / ___| _ __ | |__ (_)_ __ __  __                                        #
4# | '_ \| | | || |/ _ \ / _ \| | | '_ \ / _` | \___ \| '_ \| '_ \| | '_ \\ \/ /                                        #
5# | |_) | |_| || | (_) | (_) | | | | | | (_| |_ ___) | |_) | | | | | | | |>  <                                         #
6# | .__/ \__, ||_|\___/ \___/|_|_|_| |_|\__, (_)____/| .__/|_| |_|_|_| |_/_/\_\                                        #
7# |_|    |___/                          |___/        |_|                                                               #
8# ==================================================================================================================== #
9# Authors:                                                                                                             #
10#   Patrick Lehmann                                                                                                    #
11#                                                                                                                      #
12# License:                                                                                                             #
13# ==================================================================================================================== #
14# Copyright 2023-2026 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"""
32Directives rendering a code coverage report: a table per directory and source file, and a source file's listing.
33
34The reports are declared in :file:`conf.py` under ``pyTooling_CodeCoverage_Packages``, each with an identifier a
35directive names in its ``:reportid:`` option. A report is a Cobertura XML file (``xml_report``) - written for any
36language, e.g. by coverage.py or gcovr - or coverage.py's JSON report (``json_report``); it is read with the code
37coverage data model of :mod:`pyEDAA.Reports.CodeCoverage`. An entry with the key ``pages`` gets a page per directory
38and source file, generated below that document name (see :mod:`pyTooling.Sphinx.CodeCoveragePages`); ``sources`` is
39the directory the report's file paths are relative to, which a source file's listing is read from:
40
41.. code-block:: Python
42
43   # doc/conf.py
44   pyTooling_CodeCoverage_Packages = {
45     "src": {
46       "name":        "myPackage",
47       "json_report": "../report/coverage/coverage.json",
48       "sources":     "..",
49       "pages":       "coverage/src",
50       "fail_below":  80,
51       "levels":      "default"
52     },
53     "vhdl": {
54       "name":        "myDesign",
55       "xml_report":  "../report/coverage/cobertura.xml",
56       "sources":     "../src",
57       "pages":       "coverage/vhdl",
58       "fail_below":  80,
59       "levels":      "default"
60     }
61   }
62
63The coverage levels - limits, descriptions and CSS classes - are declared under ``pyTooling_CodeCoverage_Levels``;
64:attr:`CodeCoverageBase.defaultCoverageDefinitions` holds the ``default`` palette.
65
66.. seealso::
67
68   :ref:`DIR/CodeCoverage`
69      |rarr| The directives' options and configuration, with examples.
70   :mod:`pyTooling.Sphinx.CodeCoveragePages`
71      |rarr| The pages per directory and source file, and the role ``:cov:``.
72"""
73from __future__                       import annotations
74
75from pathlib                          import Path
76from typing                           import TYPE_CHECKING, Any, ClassVar, Optional as Nullable, TypedDict, Union
77
78from docutils                         import nodes
79from docutils.parsers.rst.directives  import flag
80from sphinx.application               import Sphinx
81from sphinx.config                    import Config
82from sphinx.util.logging              import getLogger
83
84from pyTooling.Common                 import getFullyQualifiedName, getTypeName
85from pyTooling.Decorators             import export
86
87from pyTooling.Sphinx                 import BaseDirective, LegendStyle, ReportExtensionError, \
88                                             ReportsPackageMissingError, SphinxExtensionError, strip, \
89                                             stripAndNormalize
90from pyTooling.Sphinx.Node            import Landscape, ReportIndentation
91from pyTooling.Sphinx.Pages           import ReportPages
92
93if TYPE_CHECKING:  # pragma: no cover
94	# pyEDAA.Reports is an optional dependency (extra 'reports'), imported where a report is read.
95	from pyEDAA.Reports.CodeCoverage    import BaseWithPath, CoverageSummary, Directory, File
96
97
98__all__ = ["CONFIG_PREFIX", "REPORT_KEYS"]
99
100CONFIG_PREFIX = "pyTooling_CodeCoverage"
101"""Prefix every configuration value of the code coverage directives carries in :file:`conf.py`."""
102
103REPORT_KEYS = {
104	"xml_report":  "Cobertura",
105	"json_report": "coverage.py",
106}
107"""The keys of a report's configuration naming its file, and the report format each stands for."""
108
109
110@export
111def compactDirectory(directory: Directory) -> Directory:
112	"""
113	Follow a chain of directories, which hold no file and exactly one directory each, to its last directory.
114
115	The report tables and pages show such a chain as one entry, e.g. ``pyTooling/Sphinx``, the last directory's.
116
117	:param directory: The first directory of the chain.
118	:returns:         The last directory of the chain; the directory itself, if it holds files or several directories.
119	"""
120	while len(directory._files) == 0 and len(directory._directories) == 1:
121		directory = next(iter(directory._directories.values()))
122
123	return directory
124
125
126@export
127def isCompacted(directory: Nullable[Directory]) -> bool:
128	"""
129	Check whether a directory is shown together with its single subdirectory: it holds no file and one directory, and
130	isn't the report's root.
131
132	:param directory: The directory, or ``None``.
133	:returns:         ``True``, if the directory is compacted with its subdirectory.
134	"""
135	return (
136		directory is not None and directory._parent is not None and len(directory._files) == 0
137		and len(directory._directories) == 1
138	)
139
140
141@export
142def compactedName(entity: BaseWithPath) -> str:
143	"""
144	Return the name a directory or file is shown with: a directory's name is joined with those of the parent
145	directories it was compacted with by :func:`compactDirectory`.
146
147	:param entity: The directory or file.
148	:returns:      The name, e.g. ``pyTooling/Sphinx``.
149	"""
150	names = [entity._name]
151	parent = entity._parent
152	if hasattr(entity, "_directories"):
153		while isCompacted(parent):
154			names.insert(0, parent._name)
155			parent = parent._parent
156
157	return "/".join(names)
158
159
160@export
161class PackageConfiguration(TypedDict):
162	"""An entry of ``pyTooling_CodeCoverage_Packages``, after :meth:`CodeCoverageBase.CheckConfiguration` read it."""
163
164	name:      str                                                #: Name of the measured project, the report's root.
165	report:    Path                                               #: The report file.
166	format:    str                                                #: The report's format, a value of :data:`REPORT_KEYS`.
167	sources:   Nullable[Path]                                     #: Directory the report's file paths are relative to.
168	pages:     Nullable[str]                                      #: Document name the pages are generated below.
169	failBelow: float                                              #: Coverage below which the package fails.
170	levels:    Union[str, dict[Union[int, str], dict[str, str]]]  #: Coverage levels, or the name of a palette.
171
172
173@export
174class CodeCoverageBase(BaseDirective):
175	"""
176	Base-class of the code coverage directives: the shared options, the configuration, the reports and the coverage
177	levels.
178
179	The configuration is checked and the reports are read once per build - by :meth:`CheckConfiguration` and
180	:meth:`ReadReports` -, into class variables every directive reads.
181	"""
182
183	option_spec: dict[str, Any] = {  # type: ignore[misc]
184		"class":    strip,
185		"reportid": stripAndNormalize
186	}  #: Mapping of option names to validator functions.
187
188	defaultCoverageDefinitions: ClassVar[dict[str, dict[Union[int, str], dict[str, str]]]] = {
189		"default": {
190			10:      {"class": "report-cov-below10",  "desc": "almost unused"},
191			20:      {"class": "report-cov-below20",  "desc": "almost unused"},
192			30:      {"class": "report-cov-below30",  "desc": "almost unused"},
193			40:      {"class": "report-cov-below40",  "desc": "poorly used"},
194			50:      {"class": "report-cov-below50",  "desc": "poorly used"},
195			60:      {"class": "report-cov-below60",  "desc": "somehow used"},
196			70:      {"class": "report-cov-below70",  "desc": "somehow used"},
197			80:      {"class": "report-cov-below80",  "desc": "somehow used"},
198			85:      {"class": "report-cov-below85",  "desc": "well used"},
199			90:      {"class": "report-cov-below90",  "desc": "well used"},
200			95:      {"class": "report-cov-below95",  "desc": "well used"},
201			100:     {"class": "report-cov-below100", "desc": "excellently used"},
202			"error": {"class": "report-cov-error",    "desc": "internal error"},
203		}
204	}  #: Predefined palettes of coverage levels, by name: a limit in percent or ``error``, to a CSS class and text.
205
206	configValues: ClassVar[dict[str, tuple[Any, str, Any]]] = {
207		"Packages": ({},                         "env", dict),
208		"Levels":   (defaultCoverageDefinitions, "env", dict),
209	}  #: Values added to :file:`conf.py`, as ``name: (default, rebuild, types)``, prefixed by :data:`CONFIG_PREFIX`.
210
211	_coverageLevelDefinitions: ClassVar[dict[str, dict[Union[int, str], dict[str, str]]]] = {}  #: Palettes, by name.
212	_packageConfigurations:    ClassVar[dict[str, PackageConfiguration]] = {}  #: Package configurations, by report ID.
213	_reports:                  ClassVar[dict[str, CoverageSummary]] = {}       #: Reports read by :meth:`ReadReports`.
214	_readErrors:               ClassVar[dict[str, Exception]] = {}             #: Why a report couldn't be read, by ID.
215
216	_cssClasses: list[str]                             #: Additional CSS classes of the table.
217	_reportID:   str                                   #: Identifier of the report in the configuration.
218	_levels:     dict[Union[int, str], dict[str, str]] #: The coverage levels of the report.
219
220	def _CheckOptions(self) -> None:
221		"""
222		Parse all directive options or use default values.
223
224		:raises SphinxExtensionError: If option ``:class:`` isn't a list of CSS class names.
225		:raises SphinxExtensionError: If option ``:reportid:`` is missing.
226		"""
227		cssClasses = self._ParseStringOption("class", "", r"(\w+)?( +\w+)*")
228
229		self._reportID = self._ParseStringOption("reportid")
230		self._cssClasses = [] if cssClasses == "" else cssClasses.split(" ")
231
232	def _PackageConfiguration(self) -> PackageConfiguration:
233		"""
234		Return the configuration of the report named by option ``:reportid:``, and take its coverage levels.
235
236		:returns:                     The report's configuration.
237		:raises ReportExtensionError: If ``pyTooling_CodeCoverage_Packages`` has no entry for the report ID.
238		"""
239		try:
240			packageConfiguration = self._packageConfigurations[self._reportID]
241		except KeyError as ex:
242			raise ReportExtensionError(f"No configuration for '{self._reportID}'") from ex
243
244		self._levels = packageConfiguration["levels"]
245		return packageConfiguration
246
247	@classmethod
248	def CheckConfiguration(cls, sphinxApplication: Sphinx, sphinxConfiguration: Config) -> None:
249		"""
250		Check the configuration values and load the coverage levels and package configurations.
251
252		:param sphinxApplication:   Sphinx application instance.
253		:param sphinxConfiguration: Sphinx configuration instance.
254		"""
255		cls._CheckLevelsConfiguration(sphinxConfiguration)
256		cls._CheckPackagesConfiguration(sphinxConfiguration)
257
258	@classmethod
259	def ReadReports(cls, sphinxApplication: Sphinx) -> None:
260		"""
261		Read every configured code coverage report, once per build.
262
263		A report that can't be read keeps the exception, which a directive or a report's pages show when they need it.
264
265		:param sphinxApplication: Sphinx application instance.
266		"""
267		getLogger(__name__).info("[REPORT] Reading code coverage reports ...")
268
269		cls._reports =    {}
270		cls._readErrors = {}
271		for reportID, packageConfiguration in cls._packageConfigurations.items():
272			try:
273				cls._reports[reportID] = cls._ReadReport(packageConfiguration)
274			except Exception as ex:
275				cls._readErrors[reportID] = ex
276
277	@classmethod
278	def GetReport(cls, reportID: str) -> CoverageSummary:
279		"""
280		Return a report read by :meth:`ReadReports`.
281
282		:param reportID:              Identifier of the report.
283		:returns:                     The report's root directory, aggregated.
284		:raises ReportExtensionError: If the report wasn't read, chained to the exception reading it raised.
285		"""
286		try:
287			return cls._reports[reportID]
288		except KeyError:
289			cause = cls._readErrors.get(reportID, None)
290			raise ReportExtensionError(f"Code coverage report '{reportID}' wasn't read.") from cause
291
292	@staticmethod
293	def _ReadReport(packageConfiguration: PackageConfiguration) -> CoverageSummary:
294		"""
295		Read a report file with the reader of its format.
296
297		:param packageConfiguration:        The report's configuration.
298		:returns:                           The report's root directory, aggregated.
299		:raises ReportsPackageMissingError: If pyEDAA.Reports, or its code coverage model, isn't installed.
300		"""
301		try:
302			if packageConfiguration["format"] == "Cobertura":
303				from pyEDAA.Reports.CodeCoverage.Cobertura  import Document
304			else:
305				from pyEDAA.Reports.CodeCoverage.CoveragePy import Document
306		except ImportError as cause:
307			raise ReportsPackageMissingError("Reading a code coverage report", "'pyEDAA.Reports' >= 1.0") from cause
308
309		return Document(packageConfiguration["report"], analyzeAndConvert=True).ToCoverageSummary()
310
311	@classmethod
312	def _CheckLevelsConfiguration(cls, sphinxConfiguration: Config) -> None:
313		"""
314		Check and load the palettes of coverage levels from ``pyTooling_CodeCoverage_Levels``.
315
316		:param sphinxConfiguration:   Sphinx configuration instance.
317		:raises ReportExtensionError: If the configuration value isn't registered.
318		:raises ReportExtensionError: If a palette has no level ``100`` or no level ``error``.
319		:raises ReportExtensionError: If a level is a keyword other than ``error``.
320		:raises ReportExtensionError: If a level is out of range 0..100.
321		:raises ReportExtensionError: If a level is neither a keyword nor an integer.
322		:raises ReportExtensionError: If a level has no CSS class or no description.
323		"""
324		variableName = f"{CONFIG_PREFIX}_Levels"
325
326		try:
327			coverageLevelDefinitions: dict[str, dict[Union[int, str], dict[str, str]]] = sphinxConfiguration[variableName]
328		except (KeyError, AttributeError) as ex:
329			raise ReportExtensionError(f"Configuration option '{variableName}' is not configured.") from ex
330
331		if "default" not in coverageLevelDefinitions:
332			cls._coverageLevelDefinitions["default"] = cls.defaultCoverageDefinitions["default"]
333
334		for key, coverageLevelDefinition in coverageLevelDefinitions.items():
335			configurationName = f"conf.py: {variableName}:[{key}]"
336			cls._coverageLevelDefinitions[key] = cls._CheckLevels(configurationName, coverageLevelDefinition)
337
338	@staticmethod
339	def _CheckLevels(
340		configurationName: str,
341		levels: dict[Union[int, str], dict[str, str]]
342	) -> dict[Union[int, str], dict[str, str]]:
343		"""
344		Check coverage levels and return the CSS class and description of each level.
345
346		:param configurationName:     The levels' name in error messages.
347		:param levels:                The coverage levels: a limit in percent or ``error``, to a CSS class and text.
348		:returns:                     The checked coverage levels.
349		:raises ReportExtensionError: If there is no level ``100`` or no level ``error``.
350		:raises ReportExtensionError: If a level is a keyword other than ``error``.
351		:raises ReportExtensionError: If a level is out of range 0..100.
352		:raises ReportExtensionError: If a level is neither a keyword nor an integer.
353		:raises ReportExtensionError: If a level has no CSS class or no description.
354		"""
355		if 100 not in levels:
356			raise ReportExtensionError(f"{configurationName}[100]: Configuration is missing.")
357		elif "error" not in levels:
358			raise ReportExtensionError(f"{configurationName}[error]: Configuration is missing.")
359
360		levelDefinition: dict[Union[int, str], dict[str, str]] = {}
361		for level, levelConfig in levels.items():
362			try:
363				if isinstance(level, str):
364					if level != "error":
365						raise ReportExtensionError(f"{configurationName}[{level}]: Level is a keyword, but not 'error'.")
366				elif not (0.0 <= int(level) <= 100.0):
367					raise ReportExtensionError(f"{configurationName}[{level}]: Level is out of range 0..100.")
368			except ValueError as ex:
369				raise ReportExtensionError(
370					f"{configurationName}[{level}]: Level is not a keyword or an integer in range 0..100."
371				) from ex
372
373			try:
374				cssClass = levelConfig["class"]
375			except KeyError as ex:
376				raise ReportExtensionError(f"{configurationName}[{level}].class: CSS class is missing.") from ex
377
378			try:
379				description = levelConfig["desc"]
380			except KeyError as ex:
381				raise ReportExtensionError(f"{configurationName}[{level}].desc: Description is missing.") from ex
382
383			levelDefinition[level] = {
384				"class": cssClass,
385				"desc": description
386			}
387
388		return levelDefinition
389
390	@classmethod
391	def _CheckPackagesConfiguration(cls, sphinxConfiguration: Config) -> None:
392		"""
393		Check and load the package configurations from ``pyTooling_CodeCoverage_Packages``.
394
395		:param sphinxConfiguration:   Sphinx configuration instance.
396		:raises ReportExtensionError: If the configuration value isn't registered.
397		:raises ReportExtensionError: If a package configuration has no ``name``.
398		:raises ReportExtensionError: If a package configuration has neither ``xml_report`` nor ``json_report``, or both.
399		:raises ReportExtensionError: If the report file doesn't exist.
400		:raises ReportExtensionError: If the ``sources`` directory doesn't exist.
401		:raises ReportExtensionError: If ``pages`` isn't a string.
402		:raises ReportExtensionError: If ``pages`` isn't a relative document name. |br|
403		                              Use a name like 'coverage/src', separated by '/', without '.' or '..'.
404		:raises ReportExtensionError: If a package configuration with ``pages`` has no ``sources``.
405		:raises ReportExtensionError: If a package configuration has no ``fail_below``.
406		:raises ReportExtensionError: If ``fail_below`` isn't an integer, or is out of range.
407		:raises ReportExtensionError: If a package configuration has no ``levels``.
408		:raises ReportExtensionError: If ``levels`` names a palette that isn't defined.
409		:raises ReportExtensionError: If ``levels`` is a dictionary with a missing or wrong level, CSS class or description.
410		:raises ReportExtensionError: If ``levels`` is neither a palette's name nor a dictionary.
411		"""
412		variableName = f"{CONFIG_PREFIX}_Packages"
413		cls._packageConfigurations = {}
414
415		try:
416			allPackages: dict[str, dict[str, Any]] = sphinxConfiguration[variableName]
417		except (KeyError, AttributeError) as ex:
418			raise ReportExtensionError(f"Configuration option '{variableName}' is not configured.") from ex
419
420		for reportID, packageConfiguration in allPackages.items():
421			configurationName = f"conf.py: {variableName}:[{reportID}]"
422
423			try:
424				packageName = packageConfiguration["name"]
425			except KeyError as ex:
426				raise ReportExtensionError(f"{configurationName}.name: Configuration is missing.") from ex
427
428			reportKeys = [key for key in REPORT_KEYS if key in packageConfiguration]
429			if len(reportKeys) != 1:
430				ex = ReportExtensionError(f"{configurationName}: Configuration needs exactly one report file.")
431				ex.add_note(f"Use one of: {', '.join(REPORT_KEYS)}")
432				raise ex
433
434			reportKey = reportKeys[0]
435			reportFile = Path(packageConfiguration[reportKey])
436			if not reportFile.exists():
437				raise ReportExtensionError(
438					f"{configurationName}.{reportKey}: Coverage report file '{reportFile}' doesn't exist."
439				) from FileNotFoundError(reportFile)
440
441			if (sources := packageConfiguration.get("sources", None)) is not None:
442				sources = Path(sources)
443				if not sources.is_dir():
444					raise ReportExtensionError(
445						f"{configurationName}.sources: Source directory '{sources}' doesn't exist."
446					) from FileNotFoundError(sources)
447
448			if (pages := packageConfiguration.get("pages", None)) is not None:
449				pages = ReportPages.CheckPagesConfiguration(configurationName, pages)
450				if sources is None:
451					raise ReportExtensionError(f"{configurationName}.sources: Configuration is missing, as 'pages' needs it.")
452
453			try:
454				failBelow = int(packageConfiguration["fail_below"]) / 100
455			except KeyError as ex:
456				raise ReportExtensionError(f"{configurationName}.fail_below: Configuration is missing.") from ex
457			except ValueError as ex:
458				raise ReportExtensionError(
459					f"{configurationName}.fail_below: '{packageConfiguration['fail_below']}' is not an integer in range 0..100."
460				) from ex
461
462			if not (0.0 <= failBelow <= 100.0):
463				raise ReportExtensionError(f"{configurationName}.fail_below: Is out of range 0..100.")
464
465			try:
466				levels = packageConfiguration["levels"]
467			except KeyError as ex:
468				raise ReportExtensionError(f"{configurationName}.levels: Configuration is missing.") from ex
469
470			if isinstance(levels, str):
471				try:
472					levelDefinition = cls._coverageLevelDefinitions[levels]
473				except KeyError as ex:
474					raise ReportExtensionError(
475						f"{configurationName}.levels: Referenced coverage levels '{levels}' are not defined in conf.py "
476						f"variable '{variableName}'."
477					) from ex
478			elif isinstance(levels, dict):
479				levelDefinition = cls._CheckLevels(f"{configurationName}.levels", levels)
480			else:
481				ex = ReportExtensionError(f"{configurationName}.levels: Is neither a palette's name nor a dictionary.")
482				ex.add_note(f"Got type '{getFullyQualifiedName(levels)}'.")
483				raise ex
484
485			cls._packageConfigurations[reportID] = {
486				"name":      packageName,
487				"report":    reportFile,
488				"format":    REPORT_KEYS[reportKey],
489				"sources":   sources,
490				"pages":     pages,
491				"failBelow": failBelow,
492				"levels":    levelDefinition
493			}
494
495	@staticmethod
496	def LevelOf(levels: dict[Union[int, str], dict[str, str]], coverage: float, configKey: str) -> str:
497		"""
498		Look up the coverage level a coverage falls into, and return one of its fields.
499
500		:param levels:    The coverage levels.
501		:param coverage:  The coverage, in range 0.0..1.0; a negative value selects level ``error``.
502		:param configKey: The field to return: ``class`` or ``desc``.
503		:returns:         The field's value of the first level whose limit is above the coverage.
504		"""
505		if coverage < 0.0:
506			return levels["error"][configKey]
507
508		for levelLimit, levelConfig in levels.items():
509			if isinstance(levelLimit, int) and (coverage * 100) < levelLimit:
510				return levelConfig[configKey]
511
512		return levels[100][configKey]
513
514	def _ConvertToColor(self, currentLevel: float, configKey: str) -> str:
515		"""
516		Look up the coverage level a coverage falls into, in the report's levels, and return one of its fields.
517
518		:param currentLevel: The coverage, in range 0.0..1.0; a negative value selects level ``error``.
519		:param configKey:    The field to return: ``class`` or ``desc``.
520		:returns:            The field's value of the first level whose limit is above the coverage.
521		"""
522		return self.LevelOf(self._levels, currentLevel, configKey)
523
524
525@export
526class CodeCoverage(CodeCoverageBase):
527	"""
528	The ``report:code-coverage`` directive: a table of a report's code coverage, per directory and source file.
529
530	A report with pages links every directory and file to its page, and lists the top-level ones in a hidden table of
531	contents, so the pages are below this document in the navigation.
532	"""
533
534	directiveName: str = "code-coverage"  #: Name the directive is invoked by.
535
536	has_content =        False                                     #: A boolean; ``True`` if content is allowed.
537	required_arguments = 0                                         #: Number of required directive arguments.
538	optional_arguments = CodeCoverageBase.optional_arguments + 1  #: Number of optional arguments.
539
540	option_spec: dict[str, Any] = CodeCoverageBase.option_spec | {  # type: ignore[misc]
541		"no-branch-coverage": flag
542	}  #: Mapping of option names to validator functions.
543
544	_noBranchCoverage: bool             #: Whether the branch coverage columns are left out.
545	_name:             str              #: Name of the measured project, shown for the report's root.
546	_coverage:         CoverageSummary  #: The report's root directory.
547
548	def _CheckOptions(self) -> None:
549		"""
550		Parse all directive options or use default values.
551
552		:raises ReportExtensionError: If ``pyTooling_CodeCoverage_Packages`` has no entry for the report ID.
553		"""
554		super()._CheckOptions()
555
556		self._noBranchCoverage = "no-branch-coverage" in self.options
557		self._name = self._PackageConfiguration()["name"]
558
559	def _GenerateCoverageTable(self) -> nodes.table:
560		"""
561		Build the table: two header rows, a row per directory and source file, and a summary row.
562
563		:returns: The table.
564		"""
565		cssClasses = ["report-codecov-table", f"report-codecov-{self._reportID}"]
566		cssClasses.extend(self._cssClasses)
567
568		columns = [
569			("Directory", [("\u2001File", 5)], None),
570			("Lines",     [("Total", 1), ("Excluded", 1), ("Covered", 1), ("Missing", 1), ("Coverage", 1)], None),
571			("Branches",  [("Total", 1), ("Covered", 1), ("Partial", 1), ("Missing", 1), ("Coverage", 1)], None),
572		]
573
574		if self._noBranchCoverage:
575			columns.pop(2)
576
577		tableGroup = self._CreateDoubleRowTableHeader(identifier=self._reportID, columns=columns, classes=cssClasses)
578		tableBody = nodes.tbody()
579		tableGroup += tableBody
580
581		self._RenderRow(tableBody, self._coverage, nodes.Text(f"📦{self._name}"), "report-package")
582		self._RenderDirectory(tableBody, self._coverage, 1)
583
584		tableRow = nodes.row("", classes=["report-summary", self._ConvertToColor(self._coverage.Coverage, "class")])
585		tableBody += tableRow
586		tableRow += nodes.entry("", nodes.Text(f"Overall ({self._coverage.FileCount} files):"))
587		self._RenderCounters(tableRow, self._coverage)
588
589		return tableGroup.parent
590
591	def _RenderDirectory(self, tableBody: nodes.tbody, directory: Directory, level: int) -> None:
592		"""
593		Add a row per directory and file in a directory, recursing into the directories.
594
595		A chain of directories holding no file and one directory each is a single row (see :func:`compactDirectory`).
596
597		:param tableBody: The table body the rows are added to.
598		:param directory: The directory.
599		:param level:     The depth in the hierarchy of the directories and files.
600		"""
601		for name in sorted(directory._directories):
602			subdirectory = compactDirectory(directory._directories[name])
603			self._RenderRow(tableBody, subdirectory, self._NameCell(subdirectory, level, "📁"), "report-directory")
604			self._RenderDirectory(tableBody, subdirectory, level + 1)
605
606		for name in sorted(directory._files):
607			file = directory._files[name]
608			self._RenderRow(tableBody, file, self._NameCell(file, level, "📄"), "report-file")
609
610	def _NameCell(self, entity: BaseWithPath, level: int, icon: str) -> ReportIndentation:
611		"""
612		Create the content of the cell naming a directory or file, indented, and linked to its page if the report has
613		pages.
614
615		:param entity: The directory or file.
616		:param level:  The number of indentation steps.
617		:param icon:   The icon written before the name.
618		:returns:      The cell's content.
619		"""
620		from pyTooling.Sphinx.CodeCoveragePages import CodeCoverageReportPages
621
622		name = compactedName(entity)
623		if CodeCoverageReportPages.HasPages(self._reportID):
624			pages = CodeCoverageReportPages.GetPages(self._reportID)
625			if (entry := pages.Entry(entity)) is not None:
626				# a reference has to be inside a text element; an inline keeps the cell free of a paragraph, as the
627				# others are
628				reference = pages.CreateReference("cov", entry, self.env.docname, name)
629				return ReportIndentation("", "", nodes.Text(icon), reference, level=level)
630
631		return ReportIndentation("", f"{icon}{name}", level=level)
632
633	def _RenderRow(self, tableBody: nodes.tbody, entity: BaseWithPath, name: nodes.Node, cssClass: str) -> None:
634		"""
635		Add a row of a directory or file: its name and counters, colored by its coverage level.
636
637		:param tableBody: The table body the row is added to.
638		:param entity:    The directory or file.
639		:param name:      The content of the name's cell.
640		:param cssClass:  The CSS class of the row's kind.
641		"""
642		tableRow = nodes.row("", classes=[cssClass, self._ConvertToColor(entity.Coverage, "class")])
643		tableBody += tableRow
644
645		tableRow += nodes.entry("", name)
646		self._RenderCounters(tableRow, entity)
647
648	def _RenderCounters(self, tableRow: nodes.row, entity: BaseWithPath) -> None:
649		"""
650		Add the cells of a directory's or file's line and - unless left out - branch counters.
651
652		:param tableRow: The row the cells are added to.
653		:param entity:   The directory or file.
654		"""
655		tableRow += nodes.entry("", nodes.Text(f"{entity.TotalLines}"))
656		tableRow += nodes.entry("", nodes.Text(f"{entity.ExcludedLines}"))
657		tableRow += nodes.entry("", nodes.Text(f"{entity.CoveredLines}"))
658		tableRow += nodes.entry("", nodes.Text(f"{entity.MissingLines}"))
659		tableRow += nodes.entry("", nodes.Text(f"{entity.LineCoverage:.1%}"))
660		if not self._noBranchCoverage:
661			tableRow += nodes.entry("", nodes.Text(f"{entity.TotalBranches}"))
662			tableRow += nodes.entry("", nodes.Text(f"{entity.CoveredBranches}"))
663			tableRow += nodes.entry("", nodes.Text(f"{entity.PartialLines}"))
664			tableRow += nodes.entry("", nodes.Text(f"{entity.MissingBranches}"))
665			tableRow += nodes.entry("", nodes.Text(f"{entity.BranchCoverage:.1%}"))
666
667	def run(self) -> list[nodes.Node]:
668		"""
669		Return the report as a table, on landscape pages in LaTeX, and - if the report has pages - a hidden table of
670		contents of its top-level directories and files.
671
672		:returns: A :class:`~pyTooling.Sphinx.Node.Landscape` container holding the table, or the error message; then
673		          the table of contents, if the report has pages.
674		"""
675		container = Landscape()
676
677		try:
678			self._CheckOptions()
679		except SphinxExtensionError as ex:
680			message = f"Caught {getTypeName(ex)} when checking options for directive '{self.directiveName}'."
681			return self._internalError(container, __name__, message, ex)
682
683		try:
684			self._coverage = self.GetReport(self._reportID)
685		except ReportExtensionError as ex:
686			cause = ex if ex.__cause__ is None else ex.__cause__
687			message = f"Caught {getTypeName(cause)} when reading code coverage report '{self._reportID}'."
688			return self._internalError(container, __name__, message, cause)
689
690		container += self._GenerateCoverageTable()
691
692		from pyTooling.Sphinx.CodeCoveragePages import CodeCoverageReportPages
693
694		if not CodeCoverageReportPages.HasPages(self._reportID):
695			return [container]
696
697		return [container, CodeCoverageReportPages.GetPages(self._reportID).TableOfContents(self.env.docname)]
698
699
700@export
701class CodeCoverageLegend(CodeCoverageBase):
702	"""
703	The ``report:code-coverage-legend`` directive: a table of a report's coverage levels.
704	"""
705
706	directiveName: str = "code-coverage-legend"  #: Name the directive is invoked by.
707
708	has_content =        False                                     #: A boolean; ``True`` if content is allowed.
709	required_arguments = 0                                         #: Number of required directive arguments.
710	optional_arguments = CodeCoverageBase.optional_arguments + 1  #: Number of optional arguments.
711
712	option_spec: dict[str, Any] = CodeCoverageBase.option_spec | {  # type: ignore[misc]
713		"style": LegendStyle.Parse
714	}  #: Mapping of option names to validator functions.
715
716	_style: LegendStyle  #: How the legend is laid out.
717
718	def _CheckOptions(self) -> None:
719		"""
720		Parse all directive options or use default values.
721
722		:raises ReportExtensionError: If ``pyTooling_CodeCoverage_Packages`` has no entry for the report ID.
723		"""
724		super()._CheckOptions()
725
726		self._style = self.options.get("style", LegendStyle.DEFAULT)
727
728		try:
729			packageConfiguration = self._packageConfigurations[self._reportID]
730		except KeyError as ex:
731			raise ReportExtensionError(f"No configuration for '{self._reportID}'") from ex
732
733		self._levels = packageConfiguration["levels"]
734
735	def _CreateHorizontalLegendTable(self, identifier: str, classes: list[str]) -> nodes.table:
736		"""
737		Build the legend as a table with a column per level.
738
739		:param identifier: Identifier of the table.
740		:param classes:    CSS classes of the table.
741		:returns:          The table.
742		"""
743		columns: list[tuple[str, Union[int, None]]] = [("Code Coverage:", 3)]
744		for level in self._levels:
745			if isinstance(level, int):
746				columns.append((f"≤{level} %", 2))
747
748		tableGroup = self._CreateSingleRowTableHeader(columns, identifier=identifier, classes=classes)
749		tableBody = nodes.tbody()
750		tableGroup += tableBody
751
752		legendRow = nodes.row("", classes=["report-codecov-legend-row"])
753		legendRow += nodes.entry("", nodes.paragraph(text="Coverage Level:"))
754		tableBody += legendRow
755		for level, config in self._levels.items():
756			if isinstance(level, int):
757				legendRow += nodes.entry(
758					"", nodes.paragraph(text=config["desc"]), classes=[self._ConvertToColor((level - 1) / 100, "class")]
759				)
760
761		return tableGroup.parent
762
763	def _CreateVerticalLegendTable(self, identifier: str, classes: list[str]) -> nodes.table:
764		"""
765		Build the legend as a table with a row per level.
766
767		:param identifier: Identifier of the table.
768		:param classes:    CSS classes of the table.
769		:returns:          The table.
770		"""
771		tableGroup = self._CreateSingleRowTableHeader([
772				("Code Coverage", 3),
773				("Coverage Level", 3)
774			],
775			identifier=identifier,
776			classes=classes
777		)
778
779		tableBody = nodes.tbody()
780		tableGroup += tableBody
781
782		for level, config in self._levels.items():
783			if isinstance(level, int):
784				tableBody += nodes.row(
785					"",
786					nodes.entry("", nodes.Text(f"≤{level} %")),
787					nodes.entry("", nodes.paragraph(text=config["desc"])),
788					classes=["report-codecov-legend-row", self._ConvertToColor((level - 1) / 100, "class")]
789				)
790
791		return tableGroup.parent
792
793	def run(self) -> list[nodes.Node]:
794		"""
795		Return the configured report's coverage levels as a table, in the requested style.
796
797		:returns: A container holding the table, or the error message.
798		"""
799		container = nodes.container()
800
801		try:
802			self._CheckOptions()
803		except SphinxExtensionError as ex:
804			message = f"Caught {getTypeName(ex)} when checking options for directive '{self.directiveName}'."
805			return self._internalError(container, __name__, message, ex)
806
807		if self._style is LegendStyle.HorizontalTable:
808			container += self._CreateHorizontalLegendTable(
809				identifier=f"{self._reportID}-legend", classes=["report-codecov-legend"]
810			)
811		elif self._style is LegendStyle.VerticalTable:
812			container += self._CreateVerticalLegendTable(
813				identifier=f"{self._reportID}-legend", classes=["report-codecov-legend"]
814			)
815		else:
816			container += nodes.paragraph(text="Unsupported legend style.")
817
818		return [container]
819
820
821@export
822class FileCoverage(CodeCoverageBase):
823	"""
824	The ``report:file-coverage`` directive: a source file's listing, each line marked by its coverage state.
825
826	The directive's argument names the file by its path in the report. The listing is read from the report's
827	``sources`` directory.
828	"""
829
830	directiveName: str = "file-coverage"  #: Name the directive is invoked by.
831
832	has_content =        False  #: A boolean; ``True`` if content is allowed.
833	required_arguments = 1      #: Number of required directive arguments.
834	optional_arguments = 2      #: Number of optional arguments.
835
836	def run(self) -> list[nodes.Node]:
837		"""
838		Return the source file's listing.
839
840		:returns: A container holding the listing, or the error message.
841		"""
842		from pyTooling.Sphinx.CodeCoveragePages import createListing
843
844		container = nodes.container(classes=["report-file-coverage"])
845
846		try:
847			self._CheckOptions()
848			sources = self._PackageConfiguration()["sources"]
849			file = self._FindFile(self.GetReport(self._reportID), self.arguments[0])
850		except SphinxExtensionError as ex:
851			message = f"Caught {getTypeName(ex)} when checking options for directive '{self.directiveName}'."
852			return self._internalError(container, __name__, message, ex)
853
854		if sources is None:
855			message = f"Code coverage report '{self._reportID}' has no 'sources' directory."
856			return self._internalError(container, __name__, message, ReportExtensionError(message))
857
858		container += createListing(file, sources, anchors=False)
859		return [container]
860
861	@staticmethod
862	def _FindFile(coverage: CoverageSummary, path: str) -> File:
863		"""
864		Find a file of a report by its path.
865
866		:param coverage:              The report's root directory.
867		:param path:                  The file's path in the report, e.g. ``src/Counter.vhdl``.
868		:returns:                     The file.
869		:raises ReportExtensionError: If the report has no such file.
870		"""
871		for file in coverage.IterateFiles():
872			if file.Path.as_posix() == path.strip("/"):
873				return file
874
875		raise ReportExtensionError(f"Code coverage report has no file '{path}'.")