DocCoverage.py
Summary
- Lines:
378 of 436 covered (86.7%)
- Excluded lines:
2
- Branches:
90 of 108 taken (83.3%), 12 lines partially
- Coverage:
86.0%
- Directory:
- Missed lines:
325-326, 359-360, 363, 387, 389, 396, 398-400, 406-407, 411-412, 446-447, 454-455, 459-460, 463, 472-475, 480, 484-485, 490-491, 498-500, 696-698, 782-802, 814-816, 822-827, 883-889, 1092-1097
Source
pyTooling/Sphinx/DocCoverage.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 the documentation coverage of a Python package as a table: per package and module; and a page per
33package and module.
34
35Unlike the other reports, there is no report file: the package's source directory is analyzed by the Python reader
36of :mod:`pyEDAA.Reports` (:class:`~pyEDAA.Reports.DocumentationCoverage.Python.Analyzer`) once per build, when the
37builder is initialized. The packages are declared in :file:`conf.py` under ``pyTooling_DocCoverage_Packages``,
38each with an identifier a directive names in its ``:reportid:`` option:
39
40.. code-block:: Python
41
42 # doc/conf.py
43 pyTooling_DocCoverage_Packages = {
44 "src": {
45 "name": "myPackage",
46 "directory": "../myPackage",
47 "pages": "doccoverage/src",
48 "fail_below": 80,
49 "levels": "default"
50 }
51 }
52
53The coverage levels - limits, descriptions and CSS classes - are declared under ``pyTooling_DocCoverage_Levels``;
54:attr:`DocCoverageBase.defaultCoverageDefinitions` holds the ``default`` palette.
55
56A package declared with the key ``pages`` gets a document per package and per module (:class:`DocCoverageReportPages`),
57named below that key's document name by its dotted name, e.g. ``<pages>/myPackage/Units/Length``:
58
59* A package's page shows its counters, tables of its sub-packages and modules, each linked to its page, and the
60 condensed listing of its ``__init__.py``.
61* A module's page shows its counters and its condensed listing (see
62 :class:`~pyTooling.Sphinx.CondensedSource.CondensedSource`): the source condensed to what is documented, syntax
63 highlighted with the Pygments lexer for Python, each line marked by its documentation state.
64
65The states are those of the items of pyEDAA.Reports' model - the analysis the counters are computed from:
66``documented``, ``undocumented``, ``ignored`` or ``excluded``, for a class, a function, a field and a variable.
67
68The pages are built by the machinery of :class:`~pyTooling.Sphinx.Pages.ReportPages`, so they are in the navigation
69below the directive ``report:doc-coverage``. Every package and module with a page is an object of domain ``report``,
70referred to by the role ``:doccov:`` by its dotted name - e.g. ``:doccov:`myPackage.Units.Length``` - or a suffix of
71it.
72
73.. seealso::
74
75 :ref:`DIR/DocCoverage`
76 |rarr| The directives' options and configuration, with examples.
77 :ref:`DIR/DocCoverage/Pages`
78 |rarr| The pages per package and module, and the role ``:doccov:``.
79 :mod:`pyTooling.Sphinx.Pages`
80 |rarr| The pages of a report: document names, registration, generation and navigation.
81 :mod:`pyTooling.Sphinx.CondensedSource`
82 |rarr| The condensed listing of a module's source.
83"""
84from __future__ import annotations
85
86from pathlib import Path
87from tokenize import open as tokenize_open
88from typing import TYPE_CHECKING, Any, ClassVar, Iterable, \
89 Optional as Nullable, TypedDict, Union
90
91from docutils import nodes
92from sphinx.application import Sphinx
93from sphinx.config import Config
94from sphinx.util.logging import getLogger
95
96from pyTooling.Common import getFullyQualifiedName, getTypeName
97from pyTooling.Decorators import export
98from pyTooling.Warning import WarningCollector
99
100from pyTooling.Sphinx import BaseDirective, LegendStyle, ReportExtensionError, \
101 ReportsPackageMissingError, SphinxExtensionError, strip, \
102 stripAndNormalize
103from pyTooling.Sphinx.CodeCoveragePages import tokenizeSource
104from pyTooling.Sphinx.CondensedSource import CondensedSource, documentationState
105from pyTooling.Sphinx.Node import DOCUMENTATION_STATES, DocCoverageListing, ReportIndentation
106from pyTooling.Sphinx.Pages import ReportPages
107
108if TYPE_CHECKING: # pragma: no cover
109 # pyEDAA.Reports is an optional dependency (extra 'reports'), imported where the coverage is computed.
110 from pyEDAA.Reports.DocumentationCoverage import CoverageCounters, Directory, DocumentationReport, Item
111
112
113__all__ = ["CONFIG_PREFIX"]
114
115CONFIG_PREFIX = "pyTooling_DocCoverage"
116"""Prefix every configuration value of the documentation coverage directives carries in :file:`conf.py`."""
117
118
119@export
120def subPackagesAndModules(package: Item) -> tuple[list[Item], list[Item]]:
121 """
122 Return the sub-packages and the modules of a package, each sorted by name.
123
124 :param package: The package's item.
125 :returns: The sub-packages' and the modules' items.
126 """
127 from pyEDAA.Reports.DocumentationCoverage import ItemKind
128
129 packages = sorted((item for item in package.Items if item.Kind is ItemKind.Package), key=lambda item: item.Name)
130 modules = sorted((item for item in package.Items if item.Kind is ItemKind.Module), key=lambda item: item.Name)
131
132 return packages, modules
133
134
135@export
136def fileCounters(unit: Item) -> CoverageCounters:
137 """
138 Return the counters of the items declared in a package's ``__init__.py`` or in a module's file.
139
140 :param unit: The package's or module's item.
141 :returns: The file's counters; zero items for a namespace package, which has no ``__init__.py``.
142 """
143 from pyEDAA.Reports.DocumentationCoverage import CoverageCounters
144
145 return CoverageCounters() if unit.File is None else unit.File.Counters
146
147
148@export
149def isUnreadable(unit: Item) -> bool:
150 """
151 Check if a package's ``__init__.py`` or a module's file couldn't be read or parsed.
152
153 The Python reader excludes such a file's item, without a documentation state.
154
155 :param unit: The package's or module's item.
156 :returns: ``True``, if the file couldn't be read or parsed.
157 """
158 from pyEDAA.Reports.DocumentationCoverage import CoverageState
159
160 return unit.File is not None and unit.Status == CoverageState.Excluded
161
162
163@export
164def coverageCell(
165 unit: Nullable[Item],
166 coverage: float,
167 levels: dict[Union[int, str], dict[str, str]]
168) -> tuple[str, str]:
169 """
170 Return the text of a row's coverage cell and the CSS class of the row's coverage level.
171
172 A package's or module's file, which couldn't be read or parsed, has the text ``can't be read`` and the level
173 ``error``.
174
175 :param unit: The package's or module's item, whose row it is; ``None`` for a summary row.
176 :param coverage: The row's coverage, in range 0.0..1.0.
177 :param levels: The coverage levels.
178 :returns: The cell's text and the level's CSS class.
179 """
180 if unit is not None and isUnreadable(unit):
181 return "can't be read", DocCoverageBase.LevelOf(levels, -1.0, "class")
182
183 return f"{coverage:.1%}", DocCoverageBase.LevelOf(levels, coverage, "class")
184
185
186@export
187class PackageConfiguration(TypedDict):
188 """An entry of ``pyTooling_DocCoverage_Packages``, after :meth:`DocCoverageBase.CheckConfiguration` read it."""
189
190 name: str #: Name of the Python package.
191 directory: Path #: The package's source directory.
192 pages: Nullable[str] #: Document name the pages are generated below.
193 failBelow: float #: Coverage below which the package fails.
194 levels: Union[str, dict[Union[int, str], dict[str, str]]] #: Coverage levels, or the name of a palette.
195
196
197@export
198class DocCoverageBase(BaseDirective):
199 """
200 Base-class of the documentation coverage directives: the shared options, the configuration and the coverage levels.
201
202 The configuration is checked and the packages are analyzed once per build - by :meth:`CheckConfiguration` and
203 :meth:`AnalyzePackages` -, into class variables every directive reads.
204 """
205
206 option_spec: dict[str, Any] = { # type: ignore[misc]
207 "class": strip,
208 "reportid": stripAndNormalize,
209 } #: Mapping of option names to validator functions.
210
211 defaultCoverageDefinitions: ClassVar[dict[str, dict[Union[int, str], dict[str, str]]]] = {
212 "default": {
213 10: {"class": "report-cov-below10", "desc": "almost undocumented"},
214 20: {"class": "report-cov-below20", "desc": "almost undocumented"},
215 30: {"class": "report-cov-below30", "desc": "almost undocumented"},
216 40: {"class": "report-cov-below40", "desc": "poorly documented"},
217 50: {"class": "report-cov-below50", "desc": "poorly documented"},
218 60: {"class": "report-cov-below60", "desc": "roughly documented"},
219 70: {"class": "report-cov-below70", "desc": "roughly documented"},
220 80: {"class": "report-cov-below80", "desc": "roughly documented"},
221 85: {"class": "report-cov-below85", "desc": "well documented"},
222 90: {"class": "report-cov-below90", "desc": "well documented"},
223 95: {"class": "report-cov-below95", "desc": "well documented"},
224 100: {"class": "report-cov-below100", "desc": "excellent documented"},
225 "error": {"class": "report-cov-error", "desc": "internal error"},
226 }
227 } #: Predefined palettes of coverage levels, by name: a limit in percent or ``error``, to a CSS class and text.
228
229 configValues: ClassVar[dict[str, tuple[Any, str, Any]]] = {
230 "Packages": ({}, "env", dict),
231 "Levels": (defaultCoverageDefinitions, "env", dict),
232 } #: Values added to :file:`conf.py`, as ``name: (default, rebuild, types)``, prefixed by :data:`CONFIG_PREFIX`.
233
234 _coverageLevelDefinitions: ClassVar[dict[str, dict[Union[int, str], dict[str, str]]]] = {} #: Palettes, by name.
235 _packageConfigurations: ClassVar[dict[str, PackageConfiguration]] = {} #: Package configurations, by report ID.
236 _reports: ClassVar[dict[str, DocumentationReport]] = {} #: Packages analyzed, by report ID.
237 _analysisErrors: ClassVar[dict[str, Exception]] = {} #: Why a package wasn't analyzed, by ID.
238
239 _cssClasses: list[str] #: Additional CSS classes of the table.
240 _reportID: str #: Identifier of the package in the configuration.
241 _levels: dict[Union[int, str], dict[str, str]] #: The coverage levels of the package.
242
243 def _CheckOptions(self) -> None:
244 """
245 Parse all directive options or use default values.
246
247 :raises SphinxExtensionError: If option ``:class:`` isn't a list of CSS class names.
248 :raises SphinxExtensionError: If option ``:reportid:`` is missing.
249 """
250 cssClasses = self._ParseStringOption("class", "", r"(\w+)?( +\w+)*")
251
252 self._reportID = self._ParseStringOption("reportid")
253 self._cssClasses = [] if cssClasses == "" else cssClasses.split(" ")
254
255 @classmethod
256 def CheckConfiguration(cls, sphinxApplication: Sphinx, sphinxConfiguration: Config) -> None:
257 """
258 Check the configuration values and load the coverage levels and package configurations.
259
260 :param sphinxApplication: Sphinx application instance.
261 :param sphinxConfiguration: Sphinx configuration instance.
262 """
263 cls._CheckLevelsConfiguration(sphinxConfiguration)
264 cls._CheckPackagesConfiguration(sphinxConfiguration)
265
266 @classmethod
267 def AnalyzePackages(cls, sphinxApplication: Sphinx) -> None:
268 """
269 Analyze every configured package, once per build.
270
271 A package that can't be analyzed keeps the exception, which a directive or a package's pages show when they need
272 it: a :exc:`ReportExtensionError`, e.g. pyEDAA.Reports isn't installed, or the package's directory doesn't exist.
273 Any other exception is a bug, and ends the build.
274
275 :param sphinxApplication: Sphinx application instance.
276 """
277 getLogger(__name__).info("[REPORT] Analyzing documentation coverage ...")
278
279 cls._reports = {}
280 cls._analysisErrors = {}
281 for reportID, packageConfiguration in cls._packageConfigurations.items():
282 try:
283 cls._reports[reportID] = cls._AnalyzePackage(packageConfiguration)
284 except ReportExtensionError as ex:
285 cls._analysisErrors[reportID] = ex
286
287 @classmethod
288 def GetReport(cls, reportID: str) -> DocumentationReport:
289 """
290 Return a package analyzed by :meth:`AnalyzePackages`.
291
292 :param reportID: Identifier of the package.
293 :returns: The package's report, aggregated; its top-level item is the package.
294 :raises ReportExtensionError: If the package wasn't analyzed, chained to the exception analyzing it raised.
295 """
296 try:
297 return cls._reports[reportID]
298 except KeyError:
299 cause = cls._analysisErrors.get(reportID, None)
300 raise ReportExtensionError(f"Documentation coverage of '{reportID}' wasn't analyzed.") from cause
301
302 @staticmethod
303 def _AnalyzePackage(packageConfiguration: PackageConfiguration) -> DocumentationReport:
304 """
305 Analyze a package's source directory with pyEDAA.Reports' Python reader.
306
307 A source file, which can't be read or parsed, is logged as a warning, and its module is excluded from the
308 analysis.
309
310 :param packageConfiguration: The package's configuration.
311 :returns: The package's report, aggregated; its top-level item is the package.
312 :raises ReportsPackageMissingError: If pyEDAA.Reports isn't installed.
313 :raises ReportExtensionError: If pyEDAA.Reports' analysis fails, e.g. the directory doesn't exist.
314 """
315 try:
316 from pyEDAA.Reports.DocumentationCoverage import DocCoverageError
317 from pyEDAA.Reports.DocumentationCoverage.Python import Analyzer
318 except ImportError as cause:
319 raise ReportsPackageMissingError("Analyzing the documentation coverage", "'pyEDAA.Reports'") from cause
320
321 packageName = packageConfiguration["name"]
322 try:
323 with WarningCollector() as warnings:
324 report = Analyzer(packageName, packageConfiguration["directory"]).Analyze()
325 except DocCoverageError as ex:
326 raise ReportExtensionError(f"Package '{packageName}' can't be analyzed.") from ex
327
328 for warning in warnings:
329 message = f"Caught {getTypeName(warning)} when analyzing package '{packageName}'.\n {warning}"
330 if (cause := warning.__cause__) is not None:
331 message += f"\n {getTypeName(cause)}: {cause}"
332
333 getLogger(__name__).warning(message)
334
335 getLogger(__name__).verbose(
336 f"[REPORT] Package '{packageName}': {report.FileCount} files analyzed in "
337 f"{report.AnalysisDuration.total_seconds():.3f} s."
338 )
339
340 return report
341
342 @classmethod
343 def _CheckLevelsConfiguration(cls, sphinxConfiguration: Config) -> None:
344 """
345 Check and load the palettes of coverage levels from ``pyTooling_DocCoverage_Levels``.
346
347 :param sphinxConfiguration: Sphinx configuration instance.
348 :raises ReportExtensionError: If the configuration value isn't registered.
349 :raises ReportExtensionError: If a palette has 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 variableName = f"{CONFIG_PREFIX}_Levels"
356
357 try:
358 coverageLevelDefinitions: dict[str, dict[Union[int, str], dict[str, str]]] = sphinxConfiguration[variableName]
359 except (KeyError, AttributeError) as ex:
360 raise ReportExtensionError(f"Configuration option '{variableName}' is not configured.") from ex
361
362 if "default" not in coverageLevelDefinitions:
363 cls._coverageLevelDefinitions["default"] = cls.defaultCoverageDefinitions["default"]
364
365 for key, coverageLevelDefinition in coverageLevelDefinitions.items():
366 configurationName = f"conf.py: {variableName}:[{key}]"
367 cls._coverageLevelDefinitions[key] = cls._CheckLevels(configurationName, coverageLevelDefinition)
368
369 @staticmethod
370 def _CheckLevels(
371 configurationName: str,
372 levels: dict[Union[int, str], dict[str, str]]
373 ) -> dict[Union[int, str], dict[str, str]]:
374 """
375 Check coverage levels and return the CSS class and description of each level.
376
377 :param configurationName: The levels' name in error messages.
378 :param levels: The coverage levels: a limit in percent or ``error``, to a CSS class and text.
379 :returns: The checked coverage levels.
380 :raises ReportExtensionError: If there is no level ``100`` or no level ``error``.
381 :raises ReportExtensionError: If a level is a keyword other than ``error``.
382 :raises ReportExtensionError: If a level is out of range 0..100.
383 :raises ReportExtensionError: If a level is neither a keyword nor an integer.
384 :raises ReportExtensionError: If a level has no CSS class or no description.
385 """
386 if 100 not in levels:
387 raise ReportExtensionError(f"{configurationName}[100]: Configuration is missing.")
388 elif "error" not in levels:
389 raise ReportExtensionError(f"{configurationName}[error]: Configuration is missing.")
390
391 levelDefinition: dict[Union[int, str], dict[str, str]] = {}
392 for level, levelConfig in levels.items():
393 try:
394 if isinstance(level, str):
395 if level != "error":
396 raise ReportExtensionError(f"{configurationName}[{level}]: Level is a keyword, but not 'error'.")
397 elif not (0.0 <= int(level) <= 100.0):
398 raise ReportExtensionError(f"{configurationName}[{level}]: Level is out of range 0..100.")
399 except ValueError as ex:
400 raise ReportExtensionError(
401 f"{configurationName}[{level}]: Level is not a keyword or an integer in range 0..100."
402 ) from ex
403
404 try:
405 cssClass = levelConfig["class"]
406 except KeyError as ex:
407 raise ReportExtensionError(f"{configurationName}[{level}].class: CSS class is missing.") from ex
408
409 try:
410 description = levelConfig["desc"]
411 except KeyError as ex:
412 raise ReportExtensionError(f"{configurationName}[{level}].desc: Description is missing.") from ex
413
414 levelDefinition[level] = {
415 "class": cssClass,
416 "desc": description
417 }
418
419 return levelDefinition
420
421 @classmethod
422 def _CheckPackagesConfiguration(cls, sphinxConfiguration: Config) -> None:
423 """
424 Check and load the package configurations from ``pyTooling_DocCoverage_Packages``.
425
426 :param sphinxConfiguration: Sphinx configuration instance.
427 :raises ReportExtensionError: If the configuration value isn't registered.
428 :raises ReportExtensionError: If a package configuration has no ``name``.
429 :raises ReportExtensionError: If a package configuration has no ``directory``.
430 :raises ReportExtensionError: If the ``directory`` doesn't exist.
431 :raises ReportExtensionError: If ``pages`` isn't a string.
432 :raises ReportExtensionError: If ``pages`` isn't a relative document name. |br|
433 Use a name like 'doccoverage/src', separated by '/', without '.' or '..'.
434 :raises ReportExtensionError: If a package configuration has no ``fail_below``.
435 :raises ReportExtensionError: If ``fail_below`` isn't an integer, or is out of range.
436 :raises ReportExtensionError: If a package configuration has no ``levels``.
437 :raises ReportExtensionError: If ``levels`` names a palette that isn't defined.
438 :raises ReportExtensionError: If ``levels`` is a dictionary with a missing or wrong level, CSS class or description.
439 :raises ReportExtensionError: If ``levels`` is neither a palette's name nor a dictionary.
440 """
441 variableName = f"{CONFIG_PREFIX}_Packages"
442 cls._packageConfigurations = {}
443
444 try:
445 allPackages: dict[str, PackageConfiguration] = sphinxConfiguration[variableName]
446 except (KeyError, AttributeError) as ex:
447 raise ReportExtensionError(f"Configuration option '{variableName}' is not configured.") from ex
448
449 for reportID, packageConfiguration in allPackages.items():
450 configurationName = f"conf.py: {variableName}:[{reportID}]"
451
452 try:
453 packageName = packageConfiguration["name"]
454 except KeyError as ex:
455 raise ReportExtensionError(f"{configurationName}.name: Configuration is missing.") from ex
456
457 try:
458 directory = Path(packageConfiguration["directory"])
459 except KeyError as ex:
460 raise ReportExtensionError(f"{configurationName}.directory: Configuration is missing.") from ex
461
462 if not directory.exists():
463 raise ReportExtensionError(
464 f"{configurationName}.directory: Directory '{directory}' doesn't exist."
465 ) from FileNotFoundError(directory)
466
467 if (pages := packageConfiguration.get("pages", None)) is not None:
468 pages = ReportPages.CheckPagesConfiguration(configurationName, pages)
469
470 try:
471 failBelow = int(packageConfiguration["fail_below"]) / 100
472 except KeyError as ex:
473 raise ReportExtensionError(f"{configurationName}.fail_below: Configuration is missing.") from ex
474 except ValueError as ex:
475 raise ReportExtensionError(
476 f"{configurationName}.fail_below: '{packageConfiguration['fail_below']}' is not an integer in range 0..100."
477 ) from ex
478
479 if not (0.0 <= failBelow <= 100.0):
480 raise ReportExtensionError(f"{configurationName}.fail_below: Is out of range 0..100.")
481
482 try:
483 levels = packageConfiguration["levels"]
484 except KeyError as ex:
485 raise ReportExtensionError(f"{configurationName}.levels: Configuration is missing.") from ex
486
487 if isinstance(levels, str):
488 try:
489 levelDefinition = cls._coverageLevelDefinitions[levels]
490 except KeyError as ex:
491 raise ReportExtensionError(
492 f"{configurationName}.levels: Referenced coverage levels '{levels}' are not defined in conf.py "
493 f"variable '{variableName}'."
494 ) from ex
495 elif isinstance(levels, dict):
496 levelDefinition = cls._CheckLevels(f"{configurationName}.levels", levels)
497 else:
498 ex = ReportExtensionError(f"{configurationName}.levels: Is neither a palette's name nor a dictionary.")
499 ex.add_note(f"Got type '{getFullyQualifiedName(levels)}'.")
500 raise ex
501
502 cls._packageConfigurations[reportID] = {
503 "name": packageName,
504 "directory": directory,
505 "pages": pages,
506 "failBelow": failBelow,
507 "levels": levelDefinition
508 }
509
510 @staticmethod
511 def LevelOf(levels: dict[Union[int, str], dict[str, str]], coverage: float, configKey: str) -> str:
512 """
513 Look up the coverage level a coverage falls into, and return one of its fields.
514
515 :param levels: The coverage levels.
516 :param coverage: The coverage, in range 0.0..1.0; a negative value selects level ``error``.
517 :param configKey: The field to return: ``class`` or ``desc``.
518 :returns: The field's value of the first level whose limit is above the coverage.
519 """
520 if coverage < 0.0:
521 return levels["error"][configKey]
522
523 for levelLimit, levelConfig in levels.items():
524 if isinstance(levelLimit, int) and (coverage * 100) < levelLimit:
525 return levelConfig[configKey]
526
527 return levels[100][configKey]
528
529 def _ConvertToColor(self, currentLevel: float, configKey: str) -> str:
530 """
531 Look up the coverage level a coverage falls into, in the package's levels, and return one of its fields.
532
533 :param currentLevel: The coverage, in range 0.0..1.0; a negative value selects level ``error``.
534 :param configKey: The field to return: ``class`` or ``desc``.
535 :returns: The field's value of the first level whose limit is above the coverage.
536 """
537 return self.LevelOf(self._levels, currentLevel, configKey)
538
539
540@export
541class DocCoverage(DocCoverageBase):
542 """
543 Base-class of the ``report:doc-coverage`` directive: the table, without an analyzer.
544
545 A package with pages links every package and module to its page.
546 """
547
548 directiveName: str = "doc-coverage" #: Name the directive is invoked by.
549
550 has_content = False #: A boolean; ``True`` if content is allowed.
551 required_arguments = 0 #: Number of required directive arguments.
552 optional_arguments = DocCoverageBase.optional_arguments + 0 #: Number of optional arguments.
553
554 _packageName: str #: Name of the Python package.
555 _directory: Path #: The package's source directory.
556 _failBelow: float #: Coverage below which the package fails.
557 _report: DocumentationReport #: The package's report, as analyzed.
558
559 def _CheckOptions(self) -> None:
560 """
561 Parse all directive options or use default values.
562
563 :raises KeyError: If ``pyTooling_DocCoverage_Packages`` has no entry for the report ID.
564 """
565 super()._CheckOptions()
566
567 packageConfiguration = self._packageConfigurations[self._reportID]
568 self._packageName = packageConfiguration["name"]
569 self._directory = packageConfiguration["directory"]
570 self._failBelow = packageConfiguration["failBelow"]
571 self._levels = packageConfiguration["levels"]
572
573 def _GenerateCoverageTable(self) -> nodes.table:
574 """
575 Build the table: a row per package and module, and a summary row.
576
577 :returns: The table.
578 """
579 cssClasses = ["report-doccov-table", f"report-doccov-{self._reportID}"]
580 cssClasses.extend(self._cssClasses)
581
582 # Create a table and table header with 5 columns
583 tableGroup = self._CreateSingleRowTableHeader(
584 identifier=self._reportID,
585 columns=[
586 ("Filename", 5),
587 ("Total", 1),
588 ("Covered", 1),
589 ("Missing", 1),
590 ("Coverage in %", 1)
591 ],
592 classes=cssClasses
593 )
594 tableBody = nodes.tbody()
595 tableGroup += tableBody
596
597 self._RenderLevel(tableBody, self._report.Items[0])
598
599 # Add a summary row
600 tableBody += nodes.row(
601 "",
602 nodes.entry("", nodes.Text(f"Overall ({self._report.FileCount} files):")),
603 nodes.entry("", nodes.Text(f"{self._report.Counters.Expected}")),
604 nodes.entry("", nodes.Text(f"{self._report.Counters.Covered}")),
605 nodes.entry("", nodes.Text(f"{self._report.Counters.Uncovered}")),
606 nodes.entry("", nodes.Text(f"{self._report.Counters.Coverage:.1%}")),
607 classes=[
608 "report-summary",
609 self._ConvertToColor(self._report.Counters.Coverage, "class")
610 ]
611 )
612
613 return tableGroup.parent
614
615 def _RenderLevel(self, tableBody: nodes.tbody, package: Item, level: int = 0) -> None:
616 """
617 Add a row for a package, then - recursively - rows for its sub-packages and modules.
618
619 A package's row counts the items declared in its ``__init__.py``, a module's row those declared in the module; a
620 file, which couldn't be read or parsed, has the coverage level ``error``. The hierarchy is shown by indentation, a
621 package by 📦 and a module by ⚙️.
622
623 :param tableBody: The table body the rows are added to.
624 :param package: The package's item.
625 :param level: Optional, the package's depth in the hierarchy. Default: ``0``.
626 """
627 def addRow(unit: Item, indentation: int, icon: str, cssClass: str) -> None:
628 """
629 Nested function adding the row of a package or module.
630
631 :param unit: The package's or module's item.
632 :param indentation: The number of indentation steps.
633 :param icon: The icon written before the name.
634 :param cssClass: The CSS class of the row's kind.
635 """
636 counters = fileCounters(unit)
637 coverage, levelClass = coverageCell(unit, counters.Coverage, self._levels)
638 tableBody.append(nodes.row(
639 "",
640 nodes.entry("", self._NameCell(unit, indentation, icon)),
641 nodes.entry("", nodes.Text(f"{counters.Expected}")),
642 nodes.entry("", nodes.Text(f"{counters.Covered}")),
643 nodes.entry("", nodes.Text(f"{counters.Uncovered}")),
644 nodes.entry("", nodes.Text(coverage)),
645 classes=[cssClass, levelClass],
646 ))
647
648 packages, modules = subPackagesAndModules(package)
649
650 addRow(package, level, "📦", "report-package")
651 for subPackage in packages:
652 self._RenderLevel(tableBody, subPackage, level + 1)
653
654 for module in modules:
655 addRow(module, level + 2, "⚙️", "report-module")
656
657 def _NameCell(self, unit: Item, level: int, icon: str) -> ReportIndentation:
658 """
659 Create the content of the cell naming a package or module, indented, and linked to its page if the package has
660 pages.
661
662 :param unit: The package's or module's item.
663 :param level: The number of indentation steps.
664 :param icon: The icon written before the name.
665 :returns: The cell's content.
666 """
667 if DocCoverageReportPages.HasPages(self._reportID):
668 pages = DocCoverageReportPages.GetPages(self._reportID)
669 if (entry := pages.Entry(unit)) is not None:
670 # a reference has to be inside a text element; an inline keeps the cell free of a paragraph, as the
671 # others are
672 reference = pages.CreateReference("doccov", entry, self.env.docname)
673 return ReportIndentation("", "", nodes.Text(icon), reference, level=level)
674
675 return ReportIndentation("", f"{icon}{unit.Name}", level=level)
676
677
678@export
679class DocStrCoverage(DocCoverage):
680 """
681 The ``report:doc-coverage`` directive: a table of a package's documentation coverage.
682 """
683
684 def run(self) -> list[nodes.Node]:
685 """
686 Return the configured package's documentation coverage as a table, and - if the package has pages - a hidden
687 table of contents of its page.
688
689 :returns: A container holding the table, or the error message; then the table of contents, if the package has
690 pages.
691 """
692 container = nodes.container()
693
694 try:
695 self._CheckOptions()
696 except SphinxExtensionError as ex:
697 message = f"Caught {getTypeName(ex)} when checking options for directive '{self.directiveName}'."
698 return self._internalError(container, __name__, message, ex)
699
700 try:
701 self._report = self.GetReport(self._reportID)
702 except ReportExtensionError as ex:
703 cause = ex if ex.__cause__ is None else ex.__cause__
704 message = f"Caught {getTypeName(cause)} when analyzing package '{self._packageName}'."
705 return self._internalError(container, __name__, message, cause)
706
707 container += self._GenerateCoverageTable()
708
709 if not DocCoverageReportPages.HasPages(self._reportID):
710 return [container]
711
712 return [container, DocCoverageReportPages.GetPages(self._reportID).TableOfContents(self.env.docname)]
713
714
715@export
716class DocCoverageLegend(DocCoverageBase):
717 """
718 The ``report:doc-coverage-legend`` directive: a table of a package's coverage levels.
719 """
720
721 directiveName: str = "doc-coverage-legend" #: Name the directive is invoked by.
722
723 has_content = False #: A boolean; ``True`` if content is allowed.
724 required_arguments = 0 #: Number of required directive arguments.
725 optional_arguments = DocCoverageBase.optional_arguments + 1 #: Number of optional arguments.
726
727 option_spec: dict[str, Any] = DocCoverageBase.option_spec | { # type: ignore[misc]
728 "style": LegendStyle.Parse
729 } #: Mapping of option names to validator functions.
730
731 _style: LegendStyle #: How the legend is laid out.
732
733 def _CheckOptions(self) -> None:
734 """
735 Parse all directive options or use default values.
736
737 :raises KeyError: If ``pyTooling_DocCoverage_Packages`` has no entry for the report ID.
738 """
739 super()._CheckOptions()
740
741 self._style = self.options.get("style", LegendStyle.DEFAULT)
742
743 packageConfiguration = self._packageConfigurations[self._reportID]
744 self._levels = packageConfiguration["levels"]
745
746 def _CreateHorizontalLegendTable(self, identifier: str, classes: list[str]) -> nodes.table:
747 """
748 Build the legend as a table with a column per level.
749
750 :param identifier: Identifier of the table.
751 :param classes: CSS classes of the table.
752 :returns: The table.
753 """
754 columns: list[tuple[str, Union[int, None]]] = [("Documentation Coverage:", 3)]
755 for level in self._levels:
756 if isinstance(level, int):
757 columns.append((f"≤{level} %", 2))
758
759 tableGroup = self._CreateSingleRowTableHeader(columns, identifier=identifier, classes=classes)
760 tableBody = nodes.tbody()
761 tableGroup += tableBody
762
763 legendRow = nodes.row("", classes=["report-doccov-legend-row"])
764 legendRow += nodes.entry("", nodes.paragraph(text="Coverage Level:"))
765 tableBody += legendRow
766 for level, config in self._levels.items():
767 if isinstance(level, int):
768 legendRow += nodes.entry(
769 "", nodes.paragraph(text=config["desc"]), classes=[self._ConvertToColor((level - 1) / 100, "class")]
770 )
771
772 return tableGroup.parent
773
774 def _CreateVerticalLegendTable(self, identifier: str, classes: list[str]) -> nodes.table:
775 """
776 Build the legend as a table with a row per level.
777
778 :param identifier: Identifier of the table.
779 :param classes: CSS classes of the table.
780 :returns: The table.
781 """
782 tableGroup = self._CreateSingleRowTableHeader([
783 ("Documentation Coverage", 3),
784 ("Coverage Level", 3)
785 ],
786 identifier=identifier,
787 classes=classes
788 )
789
790 tableBody = nodes.tbody()
791 tableGroup += tableBody
792
793 for level, config in self._levels.items():
794 if isinstance(level, int):
795 tableBody += nodes.row(
796 "",
797 nodes.entry("", nodes.Text(f"≤{level} %")),
798 nodes.entry("", nodes.paragraph(text=config["desc"])),
799 classes=["report-doccov-legend-row", self._ConvertToColor((level - 1) / 100, "class")]
800 )
801
802 return tableGroup.parent
803
804 def run(self) -> list[nodes.Node]:
805 """
806 Return the configured package's coverage levels as a table, in the requested style.
807
808 :returns: A container holding the table, or the error message.
809 """
810 container = nodes.container()
811
812 try:
813 self._CheckOptions()
814 except SphinxExtensionError as ex:
815 message = f"Caught {getTypeName(ex)} when checking options for directive '{self.directiveName}'."
816 return self._internalError(container, __name__, message, ex)
817
818 if self._style is LegendStyle.HorizontalTable:
819 container += self._CreateHorizontalLegendTable(
820 identifier=f"{self._reportID}-legend", classes=["report-doccov-legend"]
821 )
822 elif self._style is LegendStyle.VerticalTable:
823 container += self._CreateVerticalLegendTable(
824 identifier=f"{self._reportID}-legend", classes=["report-doccov-legend"]
825 )
826 else:
827 container += nodes.paragraph(text="Unsupported legend style.")
828
829 return [container]
830
831
832@export
833class DocCoverageReportPages(ReportPages["Item"]):
834 """
835 The pages of one package's documentation coverage: a page per package and per module.
836 """
837
838 _pages: ClassVar[dict[str, DocCoverageReportPages]] = {} #: The pages of every analyzed package, by report ID.
839 _separator: ClassVar[str] = "." #: Separator of the names in a dotted name.
840
841 _directory: Path #: The package's source directory.
842 _levels: dict[Union[int, str], dict[str, str]] #: The package's coverage levels, coloring the tables.
843
844 def __init__(
845 self,
846 reportID: str,
847 prefix: str,
848 directory: Path,
849 package: Item,
850 levels: dict[Union[int, str], dict[str, str]]
851 ) -> None:
852 """
853 Compute the document names and entries of a package's packages and modules.
854
855 :param reportID: Identifier of the package.
856 :param prefix: Document name the pages are generated below.
857 :param directory: The package's source directory.
858 :param package: The package's item, the root of the pages.
859 :param levels: The package's coverage levels.
860 """
861 super().__init__(reportID, prefix, directory, [("doccov", package)])
862
863 self._directory = directory.resolve()
864 self._levels = levels
865
866 @classmethod
867 def CreatePages(cls, sphinxApplication: Sphinx) -> None:
868 """
869 Call-back for Sphinx' ``builder-inited`` event, after the packages are analyzed: compute and register the pages.
870
871 Only a package with the key ``pages`` gets pages. A package that couldn't be analyzed gets none; this is logged
872 as an error.
873
874 :param sphinxApplication: Sphinx application instance.
875 """
876 cls._pages = {}
877 for reportID, packageConfiguration in DocCoverageBase._packageConfigurations.items():
878 if (prefix := packageConfiguration["pages"]) is None:
879 continue
880
881 try:
882 report = DocCoverageBase.GetReport(reportID)
883 except ReportExtensionError as ex:
884 cause = ex if ex.__cause__ is None else ex.__cause__
885 getLogger(__name__).error(
886 f"Caught {getTypeName(ex)} when generating the pages of documentation coverage '{reportID}'.\n"
887 f" {getTypeName(cause)}: {cause}"
888 )
889 continue
890
891 pages = cls(reportID, prefix, packageConfiguration["directory"], report.Items[0], packageConfiguration["levels"])
892 cls._pages[reportID] = pages
893 pages.Register(sphinxApplication.env)
894
895 def _Name(self, entity: Item) -> str:
896 """
897 Return a package's or module's name; the root package's may be dotted.
898
899 The root package has the configured name, e.g. ``pyTooling.Sphinx``, which is one element of a path: a reference
900 names it in full.
901
902 :param entity: The package's or module's item.
903 :returns: The name.
904 """
905 return entity.Name
906
907 def _Title(self, entity: Item) -> Nullable[str]:
908 """
909 Return no title, as a package or module has none.
910
911 :param entity: The package's or module's item.
912 :returns: ``None``
913 """
914 return None
915
916 def _Children(self, roleName: str, entity: Item) -> Iterable[tuple[str, Item]]:
917 """
918 Return a package's sub-packages, then its modules, each sorted by name; a module has none.
919
920 :param roleName: The role referring to the object: ``doccov``.
921 :param entity: The package's or module's item.
922 :returns: The children as ``(role name, object)`` pairs.
923 """
924 packages, modules = subPackagesAndModules(entity)
925
926 return [("doccov", child) for child in (*packages, *modules)]
927
928 def _Directory(self, package: Item) -> Directory:
929 """
930 Return a package's directory in the report's physical hierarchy.
931
932 :param package: The package's item.
933 :returns: The directory; the report itself for the root package.
934 """
935 names: list[str] = []
936 unit = package
937 while unit.Parent is not unit.Root:
938 names.insert(0, unit.Name)
939 unit = unit.Parent
940
941 directory = package.Root
942 for name in names:
943 directory = directory.Directories[Path(name)]
944
945 return directory
946
947 def _Source(self, entity: Item) -> Path:
948 """
949 Return the source of a package's or module's page: the module's file, the package's ``__init__.py`` - or its
950 directory, if it has none.
951
952 :param entity: The package's or module's item.
953 :returns: The page's source file.
954 """
955 if entity.File is not None:
956 return self._directory / entity.File.Path
957
958 return self._directory / self._Directory(entity).Path
959
960 def _Page(self, docName: str, roleName: str, entity: Item) -> nodes.section:
961 """
962 Build a package's or module's page: its summary, a package's tables of sub-packages and modules, and the
963 condensed listing.
964
965 :param docName: Name of the page's document.
966 :param roleName: The role referring to the object: ``doccov``.
967 :param entity: The package's or module's item.
968 :returns: The page's top section.
969 """
970 page = nodes.section("", nodes.title(entity.Name, entity.Name), ids=["report-doccov-page"])
971 page += (summarySection := self._SummarySection(docName, entity))
972
973 children = self._ChildrenSections(page, docName, entity)
974 if len(children) > 0:
975 # body elements precede a section's subsections, so the hidden table of contents goes before 'Summary'
976 page.insert(page.index(summarySection), self._TableOfContents(docName, children))
977
978 if entity.File is not None:
979 page += self._SourceSection(entity, self._Source(entity))
980
981 return page
982
983 def _SummarySection(self, docName: str, entity: Item) -> nodes.section:
984 """
985 Create a page's section *Summary*: the parent package, the doc-string's state, and the counters - of the module's
986 file or the package's ``__init__.py``, and a package's overall -, colored by coverage level. A file, which
987 couldn't be read or parsed, has the coverage level ``error``.
988
989 :param docName: Name of the page's document.
990 :param entity: The package's or module's item.
991 :returns: The section.
992 """
993 from pyEDAA.Reports.DocumentationCoverage import ItemKind
994
995 section = nodes.section("", nodes.title("Summary", "Summary"), ids=["summary"])
996 section += (fieldList := nodes.field_list(classes=["report-doccov-fields"]))
997 if entity.Parent is not entity.Root:
998 self._Field(fieldList, "Package", self._Reference("doccov", entity.Parent, docName))
999
1000 if isUnreadable(entity):
1001 self._Field(fieldList, "Doc-string", nodes.Text("can't be read"))
1002 elif entity.File is not None:
1003 self._Field(fieldList, "Doc-string", nodes.Text(DOCUMENTATION_STATES[documentationState(entity.Status)]))
1004
1005 table, tableBody = self._Table(
1006 [("File", 6), ("Expected", 2), ("Covered", 2), ("Uncovered", 2), ("Coverage", 2)],
1007 ["report-doccov-table", "report-doccov-summary"]
1008 )
1009 section += table
1010
1011 rows = []
1012 if entity.File is not None:
1013 rows.append((f"{entity.File.Name}", "", entity, fileCounters(entity)))
1014
1015 if entity.Kind is ItemKind.Package:
1016 rows.append((f"Overall ({self._Directory(entity).FileCount} files)", "report-summary", None, entity.Counters))
1017
1018 for name, cssClass, unit, counters in rows:
1019 coverage, level = coverageCell(unit, counters.Coverage, self._levels)
1020 tableBody += (row := nodes.row("", classes=[cssClass, level] if cssClass != "" else [level]))
1021 row += nodes.entry("", nodes.paragraph("", name))
1022 row += nodes.entry("", nodes.paragraph("", f"{counters.Expected}"))
1023 row += nodes.entry("", nodes.paragraph("", f"{counters.Covered}"))
1024 row += nodes.entry("", nodes.paragraph("", f"{counters.Uncovered}"))
1025 row += nodes.entry("", nodes.paragraph("", coverage))
1026
1027 return section
1028
1029 def _ChildrenSections(self, page: nodes.section, docName: str, package: Item) -> list[str]:
1030 """
1031 Add a package's sections of its sub-packages and modules: a table each, every row linked to its page and colored
1032 by coverage level. A sub-package's row counts it with all its modules; a module has none. A package or module,
1033 whose file couldn't be read or parsed, has the coverage level ``error``.
1034
1035 :param page: The package's page.
1036 :param docName: Name of the page's document.
1037 :param package: The package's or module's item.
1038 :returns: The document names of the listed pages.
1039 """
1040 packages, modules = subPackagesAndModules(package)
1041
1042 children: list[str] = []
1043 for title, column, identifier, units in (
1044 ("Packages", "Package", "packages", packages),
1045 ("Modules", "Module", "modules", modules),
1046 ):
1047 if len(units) == 0:
1048 continue
1049
1050 page += (section := nodes.section("", nodes.title(title, title), ids=[identifier]))
1051 table, tableBody = self._Table(
1052 [(column, 6), ("Expected", 2), ("Covered", 2), ("Uncovered", 2), ("Coverage", 2)],
1053 ["report-doccov-table", "report-doccov-children"]
1054 )
1055 section += table
1056 for unit in units:
1057 coverage, level = coverageCell(unit, unit.Counters.Coverage, self._levels)
1058 tableBody += (row := nodes.row("", classes=[level]))
1059 row += nodes.entry("", nodes.paragraph("", "", self._Reference("doccov", unit, docName)))
1060 row += nodes.entry("", nodes.paragraph("", f"{unit.Counters.Expected}"))
1061 row += nodes.entry("", nodes.paragraph("", f"{unit.Counters.Covered}"))
1062 row += nodes.entry("", nodes.paragraph("", f"{unit.Counters.Uncovered}"))
1063 row += nodes.entry("", nodes.paragraph("", coverage))
1064 if (entry := self.Entry(unit)) is not None:
1065 children.append(entry.docName)
1066
1067 return children
1068
1069 def _SourceSection(self, entity: Item, sourceFile: Path) -> nodes.section:
1070 """
1071 Create a page's section *Source*: the condensed listing of the module, or of the package's ``__init__.py``.
1072
1073 The source file is read in its declared encoding. A source file, which the analysis couldn't read or parse, isn't
1074 read again; the section says so. A source file, which can't be read or parsed now, is logged as a warning, and
1075 the section says so.
1076
1077 :param entity: The package's or module's item, which has a file.
1078 :param sourceFile: The source file.
1079 :returns: The section.
1080 """
1081 section = nodes.section("", nodes.title("Source", "Source"), ids=["source"])
1082 path = (entity.Root.Name / entity.File.Path).as_posix()
1083 if isUnreadable(entity):
1084 section += nodes.paragraph(text=f"The source file '{path}' can't be read or parsed.")
1085 return section
1086
1087 try:
1088 with tokenize_open(sourceFile) as file:
1089 source = file.read()
1090
1091 condensedSource = CondensedSource.Parse(source, path, entity)
1092 except (OSError, SyntaxError, ValueError) as ex:
1093 getLogger(__name__).warning(
1094 f"Source file '{sourceFile}' of the documentation coverage can't be read or parsed: {ex}"
1095 )
1096 section += nodes.paragraph(text=f"The source file '{path}' can't be read or parsed.")
1097 return section
1098
1099 sourceLines = source.split("\n")
1100 tokens = tokenizeSource(source, sourceFile.name)
1101 ellipsis = tokenizeSource("...\n", sourceFile.name)[0]
1102
1103 lines: list[tuple[Nullable[int], str, list[tuple[str, str]]]] = []
1104 text: list[str] = []
1105 for number, state, indentation in condensedSource.Lines:
1106 if number is None:
1107 lines.append((None, "", [("", indentation), *ellipsis]))
1108 text.append(f"{indentation}...")
1109 else:
1110 lines.append((number, state, tokens[number - 1]))
1111 text.append(sourceLines[number - 1])
1112
1113 plainText = "\n".join(text)
1114 section += DocCoverageListing(plainText, plainText, lines=lines, path=path)
1115
1116 return section