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:
- 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}'.")