DocCoverage.py
Summary
- Lines:
381 of 440 covered (86.6%)
- Excluded lines:
2
- Branches:
91 of 110 taken (82.7%), 13 lines partially
- Coverage:
85.8%
- 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, 783-803, 815-817, 824-831, 887-893, 1096-1101
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, Optional as Nullable
89from typing import 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
97from pyTooling.Decorators import export
98from pyTooling.Warning import WarningCollector
99
100from pyTooling.Sphinx import BaseDirective, LegendStyle, ReportExtensionError
101from pyTooling.Sphinx import ReportsPackageMissingError, SphinxExtensionError, strip
102from pyTooling.Sphinx import 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
115#: Prefix every configuration value of the documentation coverage directives carries in :file:`conf.py`.
116CONFIG_PREFIX = "pyTooling_DocCoverage"
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 {warning.__class__.__name__} when analyzing package '{packageName}'.\n {warning}"
330 if (cause := warning.__cause__) is not None:
331 message += f"\n {cause.__class__.__name__}: {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 {ex.__class__.__name__} 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 {cause.__class__.__name__} 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": stripAndNormalize
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 SphinxExtensionError: If option ``:style:`` names no :class:`~pyTooling.Sphinx.LegendStyle`.
738 :raises KeyError: If ``pyTooling_DocCoverage_Packages`` has no entry for the report ID.
739 """
740 super()._CheckOptions()
741
742 self._style = self._ParseEnumOption("style", LegendStyle, LegendStyle.horizontal_table)
743
744 packageConfiguration = self._packageConfigurations[self._reportID]
745 self._levels = packageConfiguration["levels"]
746
747 def _CreateHorizontalLegendTable(self, identifier: str, classes: list[str]) -> nodes.table:
748 """
749 Build the legend as a table with a column per level.
750
751 :param identifier: Identifier of the table.
752 :param classes: CSS classes of the table.
753 :returns: The table.
754 """
755 columns: list[tuple[str, Union[int, None]]] = [("Documentation Coverage:", 3)]
756 for level in self._levels:
757 if isinstance(level, int):
758 columns.append((f"≤{level} %", 2))
759
760 tableGroup = self._CreateSingleRowTableHeader(columns, identifier=identifier, classes=classes)
761 tableBody = nodes.tbody()
762 tableGroup += tableBody
763
764 legendRow = nodes.row("", classes=["report-doccov-legend-row"])
765 legendRow += nodes.entry("", nodes.paragraph(text="Coverage Level:"))
766 tableBody += legendRow
767 for level, config in self._levels.items():
768 if isinstance(level, int):
769 legendRow += nodes.entry(
770 "", nodes.paragraph(text=config["desc"]), classes=[self._ConvertToColor((level - 1) / 100, "class")]
771 )
772
773 return tableGroup.parent
774
775 def _CreateVerticalLegendTable(self, identifier: str, classes: list[str]) -> nodes.table:
776 """
777 Build the legend as a table with a row per level.
778
779 :param identifier: Identifier of the table.
780 :param classes: CSS classes of the table.
781 :returns: The table.
782 """
783 tableGroup = self._CreateSingleRowTableHeader([
784 ("Documentation Coverage", 3),
785 ("Coverage Level", 3)
786 ],
787 identifier=identifier,
788 classes=classes
789 )
790
791 tableBody = nodes.tbody()
792 tableGroup += tableBody
793
794 for level, config in self._levels.items():
795 if isinstance(level, int):
796 tableBody += nodes.row(
797 "",
798 nodes.entry("", nodes.Text(f"≤{level} %")),
799 nodes.entry("", nodes.paragraph(text=config["desc"])),
800 classes=["report-doccov-legend-row", self._ConvertToColor((level - 1) / 100, "class")]
801 )
802
803 return tableGroup.parent
804
805 def run(self) -> list[nodes.Node]:
806 """
807 Return the configured package's coverage levels as a table, in the requested style.
808
809 :returns: A container holding the table, or the error message.
810 """
811 container = nodes.container()
812
813 try:
814 self._CheckOptions()
815 except SphinxExtensionError as ex:
816 message = f"Caught {ex.__class__.__name__} when checking options for directive '{self.directiveName}'."
817 return self._internalError(container, __name__, message, ex)
818
819 if LegendStyle.Table in self._style:
820 if LegendStyle.Horizontal in self._style:
821 container += self._CreateHorizontalLegendTable(
822 identifier=f"{self._reportID}-legend", classes=["report-doccov-legend"]
823 )
824 elif LegendStyle.Vertical in self._style:
825 container += self._CreateVerticalLegendTable(
826 identifier=f"{self._reportID}-legend", classes=["report-doccov-legend"]
827 )
828 else:
829 container += nodes.paragraph(text="Unsupported legend style.")
830 else:
831 container += nodes.paragraph(text="Unsupported legend style.")
832
833 return [container]
834
835
836@export
837class DocCoverageReportPages(ReportPages):
838 """
839 The pages of one package's documentation coverage: a page per package and per module.
840 """
841
842 _reportPages: ClassVar[dict[str, ReportPages]] = {} #: The pages of every analyzed package with pages, by report ID.
843 _separator: ClassVar[str] = "." #: Separator of the names in a dotted name.
844
845 _directory: Path #: The package's source directory.
846 _levels: dict[Union[int, str], dict[str, str]] #: The package's coverage levels, coloring the tables.
847
848 def __init__(
849 self,
850 reportID: str,
851 prefix: str,
852 directory: Path,
853 package: Item,
854 levels: dict[Union[int, str], dict[str, str]]
855 ) -> None:
856 """
857 Compute the document names and entries of a package's packages and modules.
858
859 :param reportID: Identifier of the package.
860 :param prefix: Document name the pages are generated below.
861 :param directory: The package's source directory.
862 :param package: The package's item, the root of the pages.
863 :param levels: The package's coverage levels.
864 """
865 super().__init__(reportID, prefix, directory, [("doccov", package)])
866
867 self._directory = directory.resolve()
868 self._levels = levels
869
870 @classmethod
871 def CreatePages(cls, sphinxApplication: Sphinx) -> None:
872 """
873 Call-back for Sphinx' ``builder-inited`` event, after the packages are analyzed: compute and register the pages.
874
875 Only a package with the key ``pages`` gets pages. A package that couldn't be analyzed gets none; this is logged
876 as an error.
877
878 :param sphinxApplication: Sphinx application instance.
879 """
880 cls._reportPages = {}
881 for reportID, packageConfiguration in DocCoverageBase._packageConfigurations.items():
882 if (prefix := packageConfiguration["pages"]) is None:
883 continue
884
885 try:
886 report = DocCoverageBase.GetReport(reportID)
887 except ReportExtensionError as ex:
888 cause = ex if ex.__cause__ is None else ex.__cause__
889 getLogger(__name__).error(
890 f"Caught {ex.__class__.__name__} when generating the pages of documentation coverage '{reportID}'.\n"
891 f" {cause.__class__.__name__}: {cause}"
892 )
893 continue
894
895 pages = cls(reportID, prefix, packageConfiguration["directory"], report.Items[0], packageConfiguration["levels"])
896 cls._reportPages[reportID] = pages
897 pages.Register(sphinxApplication.env)
898
899 def _Name(self, entity: Item) -> str:
900 """
901 Return a package's or module's name; the root package's may be dotted.
902
903 The root package has the configured name, e.g. ``pyTooling.Sphinx``, which is one element of a path: a reference
904 names it in full.
905
906 :param entity: The package's or module's item.
907 :returns: The name.
908 """
909 return entity.Name
910
911 def _Title(self, entity: Item) -> Nullable[str]:
912 """
913 Return no title, as a package or module has none.
914
915 :param entity: The package's or module's item.
916 :returns: ``None``
917 """
918 return None
919
920 def _Children(self, roleName: str, entity: Item) -> Iterable[tuple[str, Item]]:
921 """
922 Return a package's sub-packages, then its modules, each sorted by name; a module has none.
923
924 :param roleName: The role referring to the object: ``doccov``.
925 :param entity: The package's or module's item.
926 :returns: The children as ``(role name, object)`` pairs.
927 """
928 packages, modules = subPackagesAndModules(entity)
929
930 return [("doccov", child) for child in (*packages, *modules)]
931
932 def _Directory(self, package: Item) -> Directory:
933 """
934 Return a package's directory in the report's physical hierarchy.
935
936 :param package: The package's item.
937 :returns: The directory; the report itself for the root package.
938 """
939 names: list[str] = []
940 unit = package
941 while unit.Parent is not unit.Root:
942 names.insert(0, unit.Name)
943 unit = unit.Parent
944
945 directory = package.Root
946 for name in names:
947 directory = directory.Directories[Path(name)]
948
949 return directory
950
951 def _Source(self, entity: Item) -> Path:
952 """
953 Return the source of a package's or module's page: the module's file, the package's ``__init__.py`` - or its
954 directory, if it has none.
955
956 :param entity: The package's or module's item.
957 :returns: The page's source file.
958 """
959 if entity.File is not None:
960 return self._directory / entity.File.Path
961
962 return self._directory / self._Directory(entity).Path
963
964 def _Page(self, docName: str, roleName: str, entity: Item) -> nodes.section:
965 """
966 Build a package's or module's page: its summary, a package's tables of sub-packages and modules, and the
967 condensed listing.
968
969 :param docName: Name of the page's document.
970 :param roleName: The role referring to the object: ``doccov``.
971 :param entity: The package's or module's item.
972 :returns: The page's top section.
973 """
974 page = nodes.section("", nodes.title(entity.Name, entity.Name), ids=["report-doccov-page"])
975 page += (summarySection := self._SummarySection(docName, entity))
976
977 children = self._ChildrenSections(page, docName, entity)
978 if len(children) > 0:
979 # body elements precede a section's subsections, so the hidden table of contents goes before 'Summary'
980 page.insert(page.index(summarySection), self._TableOfContents(docName, children))
981
982 if entity.File is not None:
983 page += self._SourceSection(entity, self._Source(entity))
984
985 return page
986
987 def _SummarySection(self, docName: str, entity: Item) -> nodes.section:
988 """
989 Create a page's section *Summary*: the parent package, the doc-string's state, and the counters - of the module's
990 file or the package's ``__init__.py``, and a package's overall -, colored by coverage level. A file, which
991 couldn't be read or parsed, has the coverage level ``error``.
992
993 :param docName: Name of the page's document.
994 :param entity: The package's or module's item.
995 :returns: The section.
996 """
997 from pyEDAA.Reports.DocumentationCoverage import ItemKind
998
999 section = nodes.section("", nodes.title("Summary", "Summary"), ids=["summary"])
1000 section += (fieldList := nodes.field_list(classes=["report-doccov-fields"]))
1001 if entity.Parent is not entity.Root:
1002 self._Field(fieldList, "Package", self._Reference("doccov", entity.Parent, docName))
1003
1004 if isUnreadable(entity):
1005 self._Field(fieldList, "Doc-string", nodes.Text("can't be read"))
1006 elif entity.File is not None:
1007 self._Field(fieldList, "Doc-string", nodes.Text(DOCUMENTATION_STATES[documentationState(entity.Status)]))
1008
1009 table, tableBody = self._Table(
1010 [("File", 6), ("Expected", 2), ("Covered", 2), ("Uncovered", 2), ("Coverage", 2)],
1011 ["report-doccov-table", "report-doccov-summary"]
1012 )
1013 section += table
1014
1015 rows = []
1016 if entity.File is not None:
1017 rows.append((f"{entity.File.Name}", "", entity, fileCounters(entity)))
1018
1019 if entity.Kind is ItemKind.Package:
1020 rows.append((f"Overall ({self._Directory(entity).FileCount} files)", "report-summary", None, entity.Counters))
1021
1022 for name, cssClass, unit, counters in rows:
1023 coverage, level = coverageCell(unit, counters.Coverage, self._levels)
1024 tableBody += (row := nodes.row("", classes=[cssClass, level] if cssClass != "" else [level]))
1025 row += nodes.entry("", nodes.paragraph("", name))
1026 row += nodes.entry("", nodes.paragraph("", f"{counters.Expected}"))
1027 row += nodes.entry("", nodes.paragraph("", f"{counters.Covered}"))
1028 row += nodes.entry("", nodes.paragraph("", f"{counters.Uncovered}"))
1029 row += nodes.entry("", nodes.paragraph("", coverage))
1030
1031 return section
1032
1033 def _ChildrenSections(self, page: nodes.section, docName: str, package: Item) -> list[str]:
1034 """
1035 Add a package's sections of its sub-packages and modules: a table each, every row linked to its page and colored
1036 by coverage level. A sub-package's row counts it with all its modules; a module has none. A package or module,
1037 whose file couldn't be read or parsed, has the coverage level ``error``.
1038
1039 :param page: The package's page.
1040 :param docName: Name of the page's document.
1041 :param package: The package's or module's item.
1042 :returns: The document names of the listed pages.
1043 """
1044 packages, modules = subPackagesAndModules(package)
1045
1046 children: list[str] = []
1047 for title, column, identifier, units in (
1048 ("Packages", "Package", "packages", packages),
1049 ("Modules", "Module", "modules", modules),
1050 ):
1051 if len(units) == 0:
1052 continue
1053
1054 page += (section := nodes.section("", nodes.title(title, title), ids=[identifier]))
1055 table, tableBody = self._Table(
1056 [(column, 6), ("Expected", 2), ("Covered", 2), ("Uncovered", 2), ("Coverage", 2)],
1057 ["report-doccov-table", "report-doccov-children"]
1058 )
1059 section += table
1060 for unit in units:
1061 coverage, level = coverageCell(unit, unit.Counters.Coverage, self._levels)
1062 tableBody += (row := nodes.row("", classes=[level]))
1063 row += nodes.entry("", nodes.paragraph("", "", self._Reference("doccov", unit, docName)))
1064 row += nodes.entry("", nodes.paragraph("", f"{unit.Counters.Expected}"))
1065 row += nodes.entry("", nodes.paragraph("", f"{unit.Counters.Covered}"))
1066 row += nodes.entry("", nodes.paragraph("", f"{unit.Counters.Uncovered}"))
1067 row += nodes.entry("", nodes.paragraph("", coverage))
1068 if (entry := self.Entry(unit)) is not None:
1069 children.append(entry.docName)
1070
1071 return children
1072
1073 def _SourceSection(self, entity: Item, sourceFile: Path) -> nodes.section:
1074 """
1075 Create a page's section *Source*: the condensed listing of the module, or of the package's ``__init__.py``.
1076
1077 The source file is read in its declared encoding. A source file, which the analysis couldn't read or parse, isn't
1078 read again; the section says so. A source file, which can't be read or parsed now, is logged as a warning, and
1079 the section says so.
1080
1081 :param entity: The package's or module's item, which has a file.
1082 :param sourceFile: The source file.
1083 :returns: The section.
1084 """
1085 section = nodes.section("", nodes.title("Source", "Source"), ids=["source"])
1086 path = (entity.Root.Name / entity.File.Path).as_posix()
1087 if isUnreadable(entity):
1088 section += nodes.paragraph(text=f"The source file '{path}' can't be read or parsed.")
1089 return section
1090
1091 try:
1092 with tokenize_open(sourceFile) as file:
1093 source = file.read()
1094
1095 condensedSource = CondensedSource.Parse(source, path, entity)
1096 except (OSError, SyntaxError, ValueError) as ex:
1097 getLogger(__name__).warning(
1098 f"Source file '{sourceFile}' of the documentation coverage can't be read or parsed: {ex}"
1099 )
1100 section += nodes.paragraph(text=f"The source file '{path}' can't be read or parsed.")
1101 return section
1102
1103 sourceLines = source.split("\n")
1104 tokens = tokenizeSource(source, sourceFile.name)
1105 ellipsis = tokenizeSource("...\n", sourceFile.name)[0]
1106
1107 lines: list[tuple[Nullable[int], str, list[tuple[str, str]]]] = []
1108 text: list[str] = []
1109 for number, state, indentation in condensedSource.Lines:
1110 if number is None:
1111 lines.append((None, "", [("", indentation), *ellipsis]))
1112 text.append(f"{indentation}...")
1113 else:
1114 lines.append((number, state, tokens[number - 1]))
1115 text.append(sourceLines[number - 1])
1116
1117 plainText = "\n".join(text)
1118 section += DocCoverageListing(plainText, plainText, lines=lines, path=path)
1119
1120 return section