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