DocCoverage.py

Summary

Lines:

378 of 436 covered (86.7%)

Excluded lines:

2

Branches:

90 of 108 taken (83.3%), 12 lines partially

Coverage:

86.0%

Directory:

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, 782-802, 814-816, 822-827, 883-889, 1092-1097

Source

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