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:

Sphinx

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