CodeCoverage.py

Summary

Lines:

302 of 361 covered (83.7%)

Excluded lines:

2

Branches:

71 of 90 taken (78.9%), 11 lines partially

Coverage:

82.7%

Directory:

Sphinx

Missed lines:

240-241, 273-274, 287-289, 305-306, 327-328, 357, 364, 366-368, 379-380, 416-417, 454-457, 462, 466-467, 574, 678-680, 684-687, 730-731, 743-761, 803-805, 809, 817-819, 858-859

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