__init__.py

Summary

Lines:

360 of 389 covered (92.5%)

Branches:

104 of 120 taken (86.7%), 8 lines partially

Coverage:

91.2%

Directory:

Sphinx

Missed lines:

347, 352, 379, 386, 488-504, 567, 732, 803, 867, 894-896, 918-926

Source

pyTooling/Sphinx/__init__.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"""
32A Sphinx extension providing the roles, nodes and directives shared by pyTooling and its sibling projects.
33
34Every project in the family used to carry its own :file:`doc/prolog.inc` - eighteen hand-copied files, 36 to 67
35lines each, already drifted apart - declaring the same roles as **ReST source** that is re-parsed into every
36document of every project. This extension declares them once:
37
38.. code-block:: Python
39
40   # doc/conf.py
41   extensions = [
42     ...,
43     "pyTooling.Sphinx",
44   ]
45
46.. rubric:: What it registers
47
48* the **style roles**, together with the stylesheet they need - so the styling is a CSS file instead of a
49  ``raw:: html`` block smuggled into every page - and the LaTeX package :file:`pyTooling.Sphinx.sty`, which strikes
50  through and underlines in LaTeX:
51
52  * ``:bolditalic:``, ``:underline:``, ``:strike:`` and ``:xlarge:`` - weight, decoration and size;
53  * ``:red:``, ``:green:``, ``:blue:`` and ``:purple:`` - colour, each from a CSS custom property a project can
54    redefine;
55  * ``:deletion:`` and ``:addition:`` - the two a diff needs.
56
57* the **code role**:
58
59  * ``:pycode:`` - highlights inline Python.
60
61* the **abbreviation roles**, named as in LaTeX' ``acro`` package:
62
63  * ``:acs:``, ``:acl:`` and ``:acf:`` - an abbreviation, its long form, or both;
64  * ``:acsp:``, ``:aclp:`` and ``:acfp:`` - their plurals.
65
66* the **directives**:
67
68  * :rst:dir:`condensed-class` - renders a class' public interface from its source;
69  * :rst:dir:`dependency-table` - renders a project's dependencies from its requirements files, which
70    :file:`conf.py` declares under ``pyTooling_Dependency_Requirements``;
71  * :rst:dir:`xmlschema-graph` - draws an XML schema as a Graphviz graph. It sets up :mod:`sphinx.ext.graphviz`
72    itself, and needs :mod:`xmlschema` only in a project that uses it.
73  * :rst:dir:`shields` - renders a project's badges from shields.io, in rows, from the coordinates its options
74    state: the GitHub repository, the PyPI package, the licenses, the workflow and the documentation's URL.
75  * :rst:dir:`tree` - draws a hierarchy written as an indented list as a tree, each node foldable in HTML.
76  * :rst:dir:`unicode-table` - lists icons with their Unicode names and code points, which HTML copies into the
77    clipboard on a click; the script doing so is linked like the stylesheet.
78  * :rst:dir:`abbreviations` - lists abbreviations, which the abbreviation roles refer to.
79
80* the domain ``report`` - unit test, code coverage and documentation coverage reports as tables -, with the
81  configuration values ``pyTooling_Unittest_Testsuites``, ``pyTooling_CodeCoverage_Packages`` and
82  ``pyTooling_DocCoverage_Packages`` declaring the reports. Reading a report needs the extra ``reports``
83  (pyEDAA.Reports), which is imported only then:
84
85  * :rst:dir:`report:unittest-summary` - a unit test report, per testsuite and testcase; a report declared with
86    ``pages`` gets a page per testsuite and testcase (:mod:`~pyTooling.Sphinx.UnittestPages`), which the roles
87    ``:ts:`` and ``:tc:`` - also ``:report:ts:`` and ``:report:tc:`` - refer to;
88  * :rst:dir:`report:code-coverage` and :rst:dir:`report:code-coverage-legend` - a code coverage report and its
89    coverage levels;
90  * :rst:dir:`report:doc-coverage` and :rst:dir:`report:doc-coverage-legend` - a package's documentation coverage
91    and its coverage levels; a package declared with ``pages`` gets a page per package and module
92    (:class:`~pyTooling.Sphinx.DocCoverage.DocCoverageReportPages`), which the role ``:doccov:`` refers to.
93
94* the **nodes** the directives emit, listed in :data:`NODES` with their visitors per output format.
95
96* the **external link icons** - in HTML, an icon behind each external link, chosen by its target, and the size behind
97  each download -, with the configuration values ``pyTooling_ExternalLink_Icons``, ``pyTooling_ExternalLink_NewTab``,
98  ``pyTooling_ExternalLink_Hosts``, ``pyTooling_ExternalLink_DownloadSizes``, ``pyTooling_ExternalLink_RemoteSizes``
99  and ``pyTooling_ExternalLink_RemoteSizeTimeout`` (:mod:`~pyTooling.Sphinx.ExternalLink`).
100
101Two classes aren't registered, because they are base-classes for a project's own directives:
102:class:`~pyTooling.Sphinx.BaseDirective` offers typed option access and table construction
103over the untyped mapping and the hand-assembled node trees docutils presents, and
104:class:`~pyTooling.Sphinx.SchemaGraph.SchemaGraph` draws a schema file named as a directive's
105argument, leaving only the reading of that schema to a derived class.
106
107.. attention::
108
109   This package requires **Python 3.12 or newer**, because it requires Sphinx 9.1 and Sphinx 9.1 does.
110
111.. seealso::
112
113   :mod:`pyTooling.Documentation`
114      |rarr| The doc-string helpers, which need no Sphinx.
115"""
116__author__ =            "Patrick Lehmann"
117__email__ =             "Paebbels@gmail.com"
118__copyright__ =         "2026-2026, Patrick Lehmann"
119__license__ =           "Apache License, Version 2.0"
120__version__ =           "1.0.0"
121__keywords__ =          ["Sphinx", "Sphinx Extension", "Documentation", "Directive", "Role", "Domain", "docutils"]
122__project_url__ =       "https://github.com/pyTooling/pyTooling.Sphinx"
123__documentation_url__ = "https://pyTooling.github.io/pyTooling.Sphinx"
124__issue_tracker_url__ = "https://GitHub.com/pyTooling/pyTooling.Sphinx/issues"
125
126from hashlib                 import md5
127from pathlib                 import Path
128from re                      import compile as re_compile, match as re_match
129from typing                  import Any, ClassVar, Iterable, Iterator, NamedTuple, Optional as Nullable
130
131from docutils                import nodes
132from sphinx.addnodes         import pending_xref
133from sphinx.application      import Sphinx
134from sphinx.builders         import Builder
135from sphinx.config           import Config
136from sphinx.directives       import ObjectDescription
137from sphinx.domains          import Domain, ObjType
138from sphinx.environment      import BuildEnvironment
139from sphinx.errors           import ExtensionError, NoUri
140from sphinx.roles            import XRefRole
141from sphinx.util.logging     import getLogger
142from sphinx.util.nodes       import make_refnode
143
144from pyTooling.Common        import StringEnum, getFullyQualifiedName, getTypeName, readResourceFile
145from pyTooling.Decorators    import export, readonly
146from pyTooling.Documentation import DocumentationError
147
148from pyTooling.Sphinx        import Resources as SphinxResources
149from pyTooling.Sphinx.HTML   import translateLandscape as translateLandscapeAsHTML, \
150                                    translateCoverageListing as translateCoverageListingAsHTML, \
151                                    translateDocCoverageListing as translateDocCoverageListingAsHTML, \
152                                    translateAbbreviation, translateTreeItem, translateTreeLabel, \
153                                    translateTreeSeparator, translateTreeDescription, translateTreeLegend, \
154                                    translateTreeLegendItem, translateCopyButton, translateEmoji, \
155                                    translateLineBreak as translateLineBreakAsHTML, \
156                                    translateReportIndentation as translateReportIndentationAsHTML, \
157                                    translateTaskListCheckbox as translateTaskListCheckboxAsHTML, \
158                                    translateFoldedDependencies, translateFoldedSummary
159from pyTooling.Sphinx.LaTeX  import translateLandscape as translateLandscapeAsLaTeX, \
160                                    translateCoverageListing as translateCoverageListingAsLaTeX, \
161                                    translateDocCoverageListing as translateDocCoverageListingAsLaTeX, \
162                                    translateLineBreak as translateLineBreakAsLaTeX, \
163                                    translateReportIndentation as translateReportIndentationAsLaTeX, \
164                                    translateTaskListCheckbox as translateTaskListCheckboxAsLaTeX
165from pyTooling.Sphinx.Node   import Abbreviation, Landscape, RegisteredNode, TreeItem, TreeLabel, TreeSeparator, \
166                                    CopyButton, CoverageListing, DocCoverageListing, TreeDescription, TreeLegend, \
167                                    Emoji, LineBreak, ReportIndentation, TaskListCheckbox, TreeLegendItem, \
168                                    FoldedDependencies, FoldedSummary
169
170
171__all__ = [
172	"STYLESHEET", "SCRIPT", "LATEX_PACKAGE", "SUBSTITUTIONS", "NODES", "LINE_ANCHOR", "REPORT_ROLES"
173]
174
175STYLESHEET = "pyTooling.css"
176"""Name of the stylesheet, in :mod:`pyTooling.Sphinx.Resources`."""
177
178SCRIPT = "pyTooling.js"
179"""
180Name of the script, in :mod:`pyTooling.Sphinx.Resources`: it gives the copy buttons of the ``unicode-table``
181directive their function.
182"""
183
184LATEX_PACKAGE = "pyTooling.Sphinx"
185"""
186Name of the LaTeX package, whose file :file:`pyTooling.Sphinx.sty` is in :mod:`pyTooling.Sphinx.Resources`: it
187defines the commands LaTeX writes for the style roles ``:strike:`` and ``:underline:``.
188"""
189
190SUBSTITUTIONS = """
191.. |degree| unicode:: U+00B0
192   :trim:
193
194.. |br| replace:: :br:`.`
195
196.. |hr| replace:: :hr:`.`
197"""
198"""
199Substitutions that have to stay substitutions, because ``|br|`` is written as one in every project.
200
201``|br|`` and ``|hr|`` delegate to the ``:br:`` and ``:hr:`` roles rather than writing HTML themselves, so they
202reach every output format instead of only HTML - see :func:`~pyTooling.Sphinx.Roles.breakRole`.
203"""
204
205NODES: tuple[RegisteredNode, ...] = (
206	{"name": "TreeItem",           "node": TreeItem,           "html": translateTreeItem},
207	{"name": "TreeLabel",          "node": TreeLabel,          "html": translateTreeLabel},
208	{"name": "TreeSeparator",      "node": TreeSeparator,      "html": translateTreeSeparator},
209	{"name": "TreeDescription",    "node": TreeDescription,    "html": translateTreeDescription},
210	{"name": "TreeLegend",         "node": TreeLegend,         "html": translateTreeLegend},
211	{"name": "TreeLegendItem",     "node": TreeLegendItem,     "html": translateTreeLegendItem},
212	{"name": "CopyButton",         "node": CopyButton,         "html": translateCopyButton},
213	{"name": "Abbreviation",       "node": Abbreviation,       "html": translateAbbreviation},
214	{"name": "Landscape",          "node": Landscape,          "html": translateLandscapeAsHTML,
215	 "latex": translateLandscapeAsLaTeX},
216	{"name": "CoverageListing",    "node": CoverageListing,    "html": translateCoverageListingAsHTML,
217	 "latex": translateCoverageListingAsLaTeX},
218	{"name": "DocCoverageListing", "node": DocCoverageListing, "html": translateDocCoverageListingAsHTML,
219	 "latex": translateDocCoverageListingAsLaTeX},
220	{"name": "Emoji",              "node": Emoji,              "html": translateEmoji},
221	{"name": "TaskListCheckbox",   "node": TaskListCheckbox,   "html": translateTaskListCheckboxAsHTML,
222	 "latex": translateTaskListCheckboxAsLaTeX},
223	{"name": "LineBreak",          "node": LineBreak,          "html": translateLineBreakAsHTML,
224	 "latex": translateLineBreakAsLaTeX},
225	{"name": "ReportIndentation",  "node": ReportIndentation,  "html": translateReportIndentationAsHTML,
226	 "latex": translateReportIndentationAsLaTeX},
227	{"name": "FoldedDependencies", "node": FoldedDependencies, "html": translateFoldedDependencies},
228	{"name": "FoldedSummary",      "node": FoldedSummary,      "html": translateFoldedSummary},
229)
230"""
231The nodes the extension registers, each with its visitors per output format.
232
233A format without visitors of its own - e.g. LaTeX for the tree's nodes - writes a node with the visitors of its
234base-class.
235"""
236
237
238@export
239class SphinxExtensionError(ExtensionError, DocumentationError):
240	"""
241	Base-exception of all exceptions raised by :mod:`pyTooling.Sphinx`.
242
243	It derives from **both** hierarchies on purpose: :exc:`~sphinx.errors.ExtensionError` is what Sphinx catches and
244	reports with the position of the directive, and :exc:`~pyTooling.Documentation.DocumentationError` is what a
245	caller of pyTooling catches. Neither would be enough alone.
246	"""
247
248
249@export
250class ReportExtensionError(SphinxExtensionError):
251	"""
252	The exception raised by the ``report`` domain.
253
254	It is raised e.g. for a mistake in the domain's configuration values or options.
255	"""
256
257
258@export
259class ReportsPackageMissingError(ReportExtensionError):
260	"""
261	The exception raised when a report is read, but the optional packages of the extra ``reports`` aren't installed.
262
263	The message names what was attempted and the packages it needs; a note says how to install them.
264	"""
265
266	def __init__(self, task: str, packages: str = "'pyEDAA.Reports'") -> None:
267		"""
268		Initialize the exception with what was attempted and the packages it needs.
269
270		:param task:     What needs the packages, e.g. ``Reading a unit test report``.
271		:param packages: Optional, the packages needed, quoted. Default: ``'pyEDAA.Reports'``.
272		"""
273		super().__init__(f"{task} needs {packages}, which isn't installed.")
274		self.add_note("Install it with: pip install pyTooling.Sphinx[reports]")
275
276
277@export
278class LegendStyle(StringEnum):
279	"""
280	How a legend directive of domain ``report`` lays out the coverage levels, as its option ``:style:`` states it.
281	"""
282
283	HorizontalTable = "horizontal-table"  #: A table with a column per level.
284	VerticalTable =   "vertical-table"    #: A table with a row per level.
285
286	DEFAULT =         HorizontalTable     #: Without the option, a legend has a column per level.
287
288
289@export
290def strip(option: str) -> str:
291	"""
292	Option converter removing surrounding whitespace.
293
294	:param option: The option's value as it was written.
295	:returns:      The value without surrounding whitespace.
296	"""
297	return option.strip()
298
299
300@export
301def stripAndNormalize(option: str) -> str:
302	"""
303	Option converter removing surrounding whitespace and lowering the case.
304
305	:param option: The option's value as it was written.
306	:returns:      The value without surrounding whitespace, in lower case.
307	"""
308	return option.strip().lower()
309
310
311@export
312class BaseDirective(ObjectDescription[str]):
313	"""
314	Base-class for a directive, offering typed option access and table construction.
315
316	A derived class sets :attr:`directiveName` - which is what the error messages name - and declares
317	``option_spec`` as any directive does. What it gets in return is a ``_Parse***Option`` per type instead of
318	reaching into :attr:`options` and validating by hand, and a ``_Create***TableHeader`` per table shape instead of
319	assembling :class:`~docutils.nodes.tgroup`, :class:`~docutils.nodes.colspec`, :class:`~docutils.nodes.thead`
320	and :class:`~docutils.nodes.row` in the right order.
321	"""
322
323	has_content =               False  #: A boolean; ``True`` if content is allowed.
324	required_arguments =        0      #: Number of required directive arguments.
325	optional_arguments =        0      #: Number of optional arguments after the required ones.
326	final_argument_whitespace = False  #: A boolean; ``True`` if the last argument may contain spaces.
327	option_spec =               {}     #: Mapping of option names to validator functions.
328
329	directiveName: str  #: Name the directive is invoked by, used in every error message.
330
331	def _ParseBooleanOption(self, optionName: str, default: Nullable[bool] = None) -> bool:
332		"""
333		Read an option written as ``yes``/``true`` or ``no``/``false``.
334
335		:param optionName:             Name of the option to read.
336		:param default:                Optional, the value to return when the option wasn't given.
337		:returns:                      The option's value.
338		:raises SphinxExtensionError:  If the option wasn't given and has no default.
339		:raises SphinxExtensionError:  If the option's value is neither of the two accepted spellings.
340		"""
341		try:
342			option = self.options[optionName]
343		except KeyError as cause:
344			if default is not None:
345				return default
346
347			raise SphinxExtensionError(
348				f"{self.directiveName}: Required option '{optionName}' not found for directive."
349			) from cause
350
351		if option in ("yes", "true"):
352			return True
353		elif option in ("no", "false"):
354			return False
355
356		raise SphinxExtensionError(
357			f"{self.directiveName}::{optionName}: '{option}' not supported for a boolean value (yes/true, no/false)."
358		)
359
360	def _ParseStringOption(self, optionName: str, default: Nullable[str] = None, regexp: str = "\\w+") -> str:
361		"""
362		Read an option that has to match a regular expression.
363
364		The pattern defaults to one or more word characters.
365
366		:param optionName:             Name of the option to read.
367		:param default:                Optional, the value to return when the option wasn't given.
368		:param regexp:                 Optional, the pattern the value has to match.
369		:returns:                      The option's value.
370		:raises SphinxExtensionError:  If the option wasn't given and has no default.
371		:raises SphinxExtensionError:  If the option's value doesn't match the pattern.
372		"""
373		try:
374			option: str = self.options[optionName]
375		except KeyError as cause:
376			if default is not None:
377				return default
378
379			raise SphinxExtensionError(
380				f"{self.directiveName}: Required option '{optionName}' not found for directive."
381			) from cause
382
383		if re_match(regexp, option):
384			return option
385
386		raise SphinxExtensionError(
387			f"{self.directiveName}::{optionName}: '{option}' not an accepted value for regexp '{regexp}'."
388		)
389
390	def _CreateSingleRowTableHeader(
391		self,
392		columns: list[tuple[str, Nullable[int]]],
393		identifier: str,
394		classes: list[str]
395	) -> nodes.tgroup:
396		"""
397		Create a table with a single header row.
398
399		:param columns:    One ``(title, width)`` pair per column; a width of ``None`` leaves it to the writer.
400		:param identifier: Identifier of the table.
401		:param classes:    CSS classes to put on the table.
402		:returns:          The table's column group, with the header row already in it.
403		"""
404		table = nodes.table("", identifier=identifier, classes=classes)
405		table += (tableGroup := nodes.tgroup(cols=(len(columns))))
406
407		# Setup column specifications
408		for _, width in columns:
409			tableGroup += nodes.colspec(colwidth=width)
410
411		tableGroup += (tableHeader := nodes.thead())
412		tableHeader += (headerRow := nodes.row())
413
414		# Setup header row
415		for columnTitle, _ in columns:
416			headerRow += nodes.entry("", nodes.Text(columnTitle))
417
418		return tableGroup
419
420	def _CreateDoubleRowTableHeader(
421		self,
422		columns: list[tuple[str, Nullable[list[tuple[str, int]]], Nullable[int]]],
423		identifier: str,
424		classes: list[str]
425	) -> nodes.tgroup:
426		"""
427		Create a table whose header spans two rows, so a column can group sub-columns.
428
429		A column's ``subColumns`` is ``None`` when it spans both header rows, and otherwise holds the
430		``(title, width)`` pairs below it.
431
432		:param columns:    One ``(title, subColumns, width)`` triple per column.
433		:param identifier: Identifier of the table.
434		:param classes:    CSS classes to put on the table.
435		:returns:          The table's column group, with both header rows already in it.
436		"""
437		columnCount = sum(len(groupColumn[1]) if groupColumn[1] is not None else 1 for groupColumn in columns)
438
439		# Create table with N columns
440		table = nodes.table("", identifier=identifier, classes=classes)
441		table += (tableGroup := nodes.tgroup(cols=columnCount))
442
443		# Setup column specifications
444		for _, more, width in columns:
445			if more is None:
446				tableGroup += nodes.colspec(colwidth=width)
447			else:
448				for _, width in more:
449					tableGroup += nodes.colspec(colwidth=width)
450
451		tableGroup += (tableHeader := nodes.thead())
452		tableHeader += (headerRow1 := nodes.row())
453
454		# Setup primary header row
455		for columnTitle, more, _ in columns:
456			if more is None:
457				headerRow1 += nodes.entry("", nodes.Text(columnTitle), morerows=1)
458			else:
459				headerRow1 += nodes.entry("", nodes.Text(columnTitle), morecols=(morecols := len(more) - 1))
460				for _ in range(morecols):
461					headerRow1 += None
462
463		# Setup secondary header row
464		tableHeader += (headerRow2 := nodes.row())
465		for columnTitle, more, _ in columns:
466			if more is None:
467				headerRow2 += None
468			else:
469				for columnTitle, _ in more:
470					headerRow2 += nodes.entry("", nodes.Text(columnTitle))
471
472		return tableGroup
473
474	def _CreateRotatedTableHeader(
475		self,
476		columns: list[tuple[str, Nullable[list[str]]]],
477		identifier: str,
478		classes: list[str]
479	) -> nodes.tgroup:
480		"""
481		Create a table whose header titles are rotated, for many narrow columns.
482
483		:param columns:    One ``(title, classes)`` pair per column; the classes are put on the header cell.
484		:param identifier: Identifier of the table.
485		:param classes:    CSS classes to put on the table.
486		:returns:          The table's column group, with the header row already in it.
487		"""
488		table = nodes.table("", identifier=identifier, classes=classes)
489		table += (tableGroup := nodes.tgroup(cols=len(columns)))
490
491		# Setup column specifications
492		for i, _ in enumerate(columns):
493			tableGroup += nodes.colspec(classes=[f"col-{i}"])
494
495		tableGroup += (tableHeader := nodes.thead())
496		tableHeader += (headerRow := nodes.row())
497
498		# Setup header row
499		for columnTitle, columnClasses in columns:
500			span = nodes.inline("", text=columnTitle)
501			div = nodes.container("", span)
502			headerRow += nodes.entry("", div, classes=[] if columnClasses is None else columnClasses)
503
504		return tableGroup
505
506	def _internalError(
507		self,
508		container: nodes.container,
509		location: str,
510		message: str,
511		exception: Exception
512	) -> list[nodes.Node]:
513		"""
514		Report an exception a directive couldn't recover from, in the log **and** on the page.
515
516		A directive that fails silently leaves a hole in the documentation that nobody notices. This puts the message
517		where a reader sees it and the traceback where a maintainer does. The exception's notes - e.g. how to install a
518		missing package - are logged below it.
519
520		:param container: The container the message is put into.
521		:param location:  Name of the logger, which is what the log line is attributed to.
522		:param message:   What went wrong, in one sentence.
523		:param exception: The exception that was caught.
524		:returns:         The container, as the list a directive's ``run`` returns.
525		"""
526		logger = getLogger(location)
527		logger.error(f"{message}")
528		logger.error(f"  {getTypeName(exception)}: {exception}")
529		for note in getattr(exception, "__notes__", ()):
530			logger.error(f"    {note}")
531
532		if exception.__cause__ is not None:
533			logger.error(f"    {getTypeName(exception.__cause__)}: {exception.__cause__}")
534		logger.exception(exception)
535
536		container += nodes.paragraph(text=message)
537
538		return [container]
539
540
541@export
542def installStaticFiles(sphinx: Sphinx) -> None:
543	"""
544	Call-back for Sphinx' ``builder-inited`` event, writing the stylesheet and the script into the build and linking
545	them.
546
547	A file is named by the hash of its content, so a browser re-reads it when it changes and re-uses it when it doesn't.
548	Older copies are removed when the content changed.
549
550	:param sphinx: The Sphinx application.
551	"""
552	staticDirectory = (Path(sphinx.outdir) / "_pyTooling_static").resolve()
553	staticDirectory.mkdir(exist_ok=True)
554	sphinx.config.html_static_path.append(str(staticDirectory))
555
556	for resource, addFile in ((STYLESHEET, sphinx.add_css_file), (SCRIPT, sphinx.add_js_file)):
557		stem, _, suffix = resource.rpartition(".")
558		content = readResourceFile(SphinxResources, resource)
559		digest = md5(content.encode("utf-8")).hexdigest()  # nosec B324 - a cache-busting name, not a signature
560		staticFile = staticDirectory / f"{stem}.{digest}.{suffix}"
561		addFile(staticFile.name)
562
563		if not staticFile.exists():
564			# Only this package's own copies - the directory is on 'html_static_path', so another extension may
565			# have written its files beside ours.
566			for outdated in staticDirectory.glob(f"{stem}.*.{suffix}"):
567				outdated.unlink()
568
569			staticFile.write_text(content, encoding="utf-8")
570
571
572@export
573def installLaTeXPackage(sphinx: Sphinx) -> None:
574	"""
575	Call-back for Sphinx' ``builder-inited`` event, writing the LaTeX package :data:`LATEX_PACKAGE` into the build of a
576	LaTeX builder, where the document's ``\\usepackage`` finds it.
577
578	:param sphinx: The Sphinx application.
579	"""
580	if sphinx.builder.format != "latex":
581		return
582
583	content = readResourceFile(SphinxResources, f"{LATEX_PACKAGE}.sty")
584	with (Path(sphinx.outdir) / f"{LATEX_PACKAGE}.sty").open("w", encoding="utf-8") as file:
585		file.write(content)
586
587
588@export
589def extendProlog(sphinx: Sphinx, config: Any) -> None:
590	"""
591	Call-back for Sphinx' ``config-inited`` event, appending the shared substitutions to ``rst_prolog``.
592
593	A role can be registered; a **substitution** cannot - ``|br|`` is substitution syntax, and every project writes
594	it that way already. Appending them here is what lets a project delete them from its own prolog without changing
595	a single document.
596
597	:param sphinx: The Sphinx application.
598	:param config: The configuration, after :file:`conf.py` was read.
599	"""
600	config.rst_prolog = (config.rst_prolog or "") + SUBSTITUTIONS
601
602
603@export
604def setLaTeXDefaults(sphinx: Sphinx, config: Config) -> None:
605	"""
606	Call-back for Sphinx' ``config-inited`` event, setting LaTeX defaults, unless :file:`conf.py` sets them.
607
608	* ``latex_table_style``: ``["booktabs"]``, without Sphinx' default ``colorrows``.
609	* ``latex_elements["maxlistdepth"]``: ``"10"`` nested lists, e.g. a :rst:dir:`tree` with more than 4 levels.
610
611	:param sphinx: The Sphinx application.
612	:param config: The configuration, after :file:`conf.py` was read.
613	"""
614	if "latex_table_style" not in config._raw_config and "latex_table_style" not in config.overrides:
615		config.latex_table_style = ["booktabs"]
616
617	config.latex_elements = {"maxlistdepth": "10"} | config.latex_elements
618
619
620@export
621class ReportEntry(NamedTuple):
622	"""
623	An object of a report shown by a generated page - a testcase, a source file, ...: where it is, and its names.
624
625	An object is shown by a page of its own, or - with an anchor - by a part of another page.
626	"""
627
628	docName:   str                    #: Name of the generated document showing the object.
629	reportID:  str                    #: Identifier of the report in its configuration value.
630	path:      tuple[str, ...]        #: Names of the objects from the report's root down to this object's own name.
631	separator: str                    #: Separator of the names in a qualified name, e.g. ``.`` or ``/``.
632	title:     Nullable[str]          #: Title of the object written for a reader, if the report has one.
633	anchor:    Nullable[str] = None   #: Anchor of the object's part of the page, or ``None`` for the whole page.
634
635	@readonly
636	def Name(self) -> str:
637		"""
638		Read-only property to return the object's own name, the last element of :attr:`path`.
639
640		:returns: The name.
641		"""
642		return self.path[-1]
643
644	@readonly
645	def QualifiedName(self) -> str:
646		"""
647		Read-only property to return the qualified name: the names of :attr:`path` joined by :attr:`separator`.
648
649		:returns: The qualified name, e.g. ``pytest.tests.unit.Arithmetic.Addition.test_Positive``.
650		"""
651		return self.separator.join(self.path)
652
653	def Matches(self, name: str) -> bool:
654		"""
655		Check whether a name is the qualified name or a suffix of it, which starts at an element of :attr:`path`.
656
657		:param name: The name as written in a reference, without a report ID.
658		:returns:    ``True`` if the name is the qualified name or one of its suffixes.
659		"""
660		return any(self.separator.join(self.path[index:]) == name for index in range(len(self.path)))
661
662
663@export
664class ReportRoleDefinition(NamedTuple):
665	"""
666	What a role of domain ``report`` refers to: a kind of object of the reports declared in a configuration value.
667	"""
668
669	objectType:  str           #: The object type, as listed by the domain, e.g. ``testcase``.
670	configValue: str           #: The configuration value declaring the reports; its first report is the default one.
671	reports:     str           #: The reports, as a warning names them, e.g. ``unit test reports``.
672	lineAnchors: bool = False  #: Whether a target may end in ``#<line number>``, linking to the line on the page.
673
674
675LINE_ANCHOR = re_compile(r"#(\d+)$")
676"""The line number a target of a role with line anchors may end in, e.g. ``#12`` of ``src/Counter.vhdl#12``."""
677
678REPORT_ROLES = {
679	"tc":     ReportRoleDefinition("testcase",  "pyTooling_Unittest_Testsuites",   "unit test reports"),
680	"ts":     ReportRoleDefinition("testsuite", "pyTooling_Unittest_Testsuites",   "unit test reports"),
681	"cov":    ReportRoleDefinition("source",    "pyTooling_CodeCoverage_Packages", "code coverage reports", True),
682	"doccov": ReportRoleDefinition("module",    "pyTooling_DocCoverage_Packages",  "documentation coverage reports"),
683}
684"""The roles referring to the objects of reports with pages, by role name."""
685
686
687@export
688class ReportRole(XRefRole):
689	"""
690	A role of domain ``report`` referring to an object of a report with pages.
691
692	Role ``tc`` refers to a testcase, ``ts`` to a testsuite.
693
694	Sphinx registers a role outside a domain without one, so the reference would be resolved by nobody; this role puts
695	its domain - ``report``, or the one of a domain derived from :class:`ReportDomain` - on the reference, and uses its
696	name as the reference's type. So it works as ``:report:tc:`` and as the global ``:tc:``.
697	"""
698
699	_domain: str  #: Name of the domain resolving the role's references.
700
701	def __init__(self, *, domain: str = "report", **kwargs: Any) -> None:
702		"""
703		Initializes a role of domain ``report``, or of a domain derived from :class:`ReportDomain`.
704
705		:param domain:      Optional, name of the domain resolving the role's references. Default: ``"report"``.
706		:param kwargs:      The parameters of :class:`~sphinx.roles.XRefRole`, e.g. ``innernodeclass``.
707		:raises ValueError: If parameter 'domain' is None.
708		:raises TypeError:  If parameter 'domain' is not of type :class:`str`.
709		"""
710		super().__init__(**kwargs)
711
712		if domain is None:
713			raise ValueError("Parameter 'domain' is None.")
714		elif not isinstance(domain, str):
715			ex = TypeError("Parameter 'domain' is not of type 'str'.")
716			ex.add_note(f"Got type '{getFullyQualifiedName(domain)}'.")
717			raise ex
718
719		self._domain = domain
720
721	def run(self) -> tuple[list[nodes.Node], list[nodes.system_message]]:
722		"""
723		Create the reference, or only the text if the role is written with ``!``.
724
725		:returns: The nodes and messages a role returns.
726		"""
727		self.refdomain = self._domain
728		self.reftype = self.name.rpartition(":")[2]
729		self.classes = ["xref", self._domain, f"{self._domain}-{self.reftype}"]
730
731		if self.disabled:
732			return self.create_non_xref_node()
733
734		return self.create_xref_node()
735
736
737@export
738class ReportDomain(Domain):
739	"""
740	The Sphinx domain ``report``, integrating unit test, code coverage and documentation coverage reports.
741
742	Its directives are registered by :func:`setup`, and read their reports from the files declared in :file:`conf.py`.
743
744	A report with generated pages contributes an object per page - e.g. a unit test report its testsuites and
745	testcases -, which the roles of :data:`REPORT_ROLES` refer to, e.g. ``:report:tc:``, also registered as ``:tc:``. A
746	reference names an object by its qualified name, or by a suffix of it, optionally preceded by the report's
747	identifier and a colon; without one, the first report of the role's configuration value is meant.
748
749	A domain of another family of reports - e.g. a build summary of a simulation framework - derives from it: it names
750	its roles in :attr:`_ROLES`, and declares :attr:`name`, :attr:`object_types`, :attr:`initial_data`, :attr:`roles` and
751	:attr:`dangling_warnings` from them, as this domain does from :data:`REPORT_ROLES`. Its roles are
752	:class:`ReportRole` instances naming the domain.
753	"""
754
755	_ROLES: ClassVar[dict[str, ReportRoleDefinition]] = REPORT_ROLES  #: The domain's roles, by role name.
756
757	name =         "report"  #: Name of the domain, the prefix of its directives.
758	label =        "rpt"     #: Name of the domain, as displayed.
759	data_version = 2         #: Version of the domain data's layout; a pickled environment of another version is dropped.
760
761	object_types = {
762		definition.objectType: ObjType(definition.objectType, roleName) for roleName, definition in REPORT_ROLES.items()
763	}  #: The objects: per role, the objects of the reports with generated pages.
764	initial_data: dict[str, Any] = {
765		roleName: {} for roleName in REPORT_ROLES
766	}  #: The data of an empty documentation: per role, no object.
767	roles = {
768		roleName: ReportRole(innernodeclass=nodes.inline, warn_dangling=True) for roleName in REPORT_ROLES
769	}  #: The roles, also registered without the domain's prefix.
770	dangling_warnings = {
771		roleName: f"{definition.objectType} '%(target)s' not found in the {definition.reports}"
772		for roleName, definition in REPORT_ROLES.items()
773	}  #: The warning for a reference to an unknown object, per role.
774
775	def AddEntry(self, roleName: str, entry: ReportEntry) -> None:
776		"""
777		Register an object, which has a generated page.
778
779		:param roleName: The role referring to the object, e.g. ``tc`` for a testcase.
780		:param entry:    The object.
781		"""
782		self.data[roleName].setdefault(entry.reportID, {})[entry.QualifiedName] = entry
783
784	def FindEntries(self, roleName: str, target: str) -> list[ReportEntry]:
785		"""
786		Find the objects a reference's target names.
787
788		A target is ``[<report ID>:]<name>``. If the part before the first colon isn't the identifier of a report, the
789		whole target is the name in the default report - the first one of the role's configuration value -, as e.g. a
790		parametrized testcase's name may contain a colon. A name is a qualified name or a suffix of one (see
791		:meth:`ReportEntry.Matches`); an exact qualified name wins over suffixes.
792
793		:param roleName: The role referring to the object, e.g. ``tc`` for a testcase.
794		:param target:   The reference's target.
795		:returns:        The matching entries: none, one, or several if the name is ambiguous.
796		"""
797		reports: dict[str, dict[str, ReportEntry]] = self.data[roleName]
798		configuredReports = list(self.env.config[self._ROLES[roleName].configValue])
799
800		reportID, separator, name = target.partition(":")
801		if separator == "" or reportID not in configuredReports:
802			if len(configuredReports) == 0:
803				return []
804
805			reportID, name = configuredReports[0], target
806
807		entries = reports.get(reportID, {})
808		if (entry := entries.get(name)) is not None:
809			return [entry]
810
811		return [entry for entry in entries.values() if entry.Matches(name)]
812
813	def clear_doc(self, docname: str) -> None:
814		"""
815		Forget the objects of a document, before it is generated again.
816
817		:param docname: Name of the document.
818		"""
819		for roleName in self._ROLES:
820			for entries in self.data[roleName].values():
821				for qualifiedName in [name for name, entry in entries.items() if entry.docName == docname]:
822					del entries[qualifiedName]
823
824	def merge_domaindata(self, docnames: Iterable[str], otherdata: dict[str, Any]) -> None:
825		"""
826		Take over the objects a parallel reader collected for its documents.
827
828		:param docnames:  Names of the documents the other reader read.
829		:param otherdata: The other reader's domain data.
830		"""
831		documents = set(docnames)
832		for roleName in self._ROLES:
833			for reportID, entries in otherdata[roleName].items():
834				ownEntries = self.data[roleName].setdefault(reportID, {})
835				for qualifiedName, entry in entries.items():
836					if entry.docName in documents:
837						ownEntries[qualifiedName] = entry
838
839	def resolve_xref(
840		self,
841		env: BuildEnvironment,
842		fromdocname: str,
843		builder: Builder,
844		typ: str,
845		target: str,
846		node: pending_xref,
847		contnode: nodes.Element
848	) -> Nullable[nodes.Element]:
849		"""
850		Resolve a reference to an object of a report into a link to its page.
851
852		The link shows the object's name, or the title written in the role; in HTML, its title written for a reader - if
853		the report has one - is shown on hover. It points to the object's anchor, if it has one, and to a line, if the
854		target ends in one. An ambiguous name is warned about and shown as text.
855
856		:param env:         The build environment.
857		:param fromdocname: Name of the document holding the reference.
858		:param builder:     The builder writing the documents.
859		:param typ:         The role's name, e.g. ``tc``.
860		:param target:      The object referred to.
861		:param node:        The reference.
862		:param contnode:    The reference's text.
863		:returns:           The link, the text if the name is ambiguous or the builder doesn't write the page, or
864		                    ``None`` if nothing matches.
865		"""
866		if typ not in self._ROLES:
867			return None
868
869		anchor = line = ""
870		if self._ROLES[typ].lineAnchors and (match := LINE_ANCHOR.search(target)) is not None:
871			target, line = target[:match.start()], match[1]
872			anchor = f"L{line}"
873
874		entries = self.FindEntries(typ, target)
875		if len(entries) == 0:
876			return None
877		elif len(entries) > 1:
878			candidates = ", ".join(sorted(f"{entry.reportID}:{entry.QualifiedName}" for entry in entries))
879			getLogger(__name__).warning(
880				f"{self._ROLES[typ].objectType} '{target}' is ambiguous, candidates: {candidates}",
881				location=node, type="ref", subtype=typ
882			)
883			return contnode
884
885		entry = entries[0]
886		if node.get("refexplicit", False):
887			content = contnode
888		else:
889			text = entry.Name if line == "" else f"{entry.Name}:{line}"
890			content = nodes.inline(text, text, classes=contnode["classes"])
891
892		try:
893			return make_refnode(builder, fromdocname, entry.docName, anchor or entry.anchor, content, entry.title)
894		except NoUri:
895			# a builder may not write every document, so a link to a page becomes the name
896			return content
897
898	def resolve_any_xref(
899		self,
900		env: BuildEnvironment,
901		fromdocname: str,
902		builder: Builder,
903		target: str,
904		node: pending_xref,
905		contnode: nodes.Element
906	) -> list[tuple[str, nodes.reference]]:
907		"""
908		Resolve a reference of role ``:any:`` to an object of a report with an unambiguous name.
909
910		:param env:         The build environment.
911		:param fromdocname: Name of the document holding the reference.
912		:param builder:     The builder writing the documents.
913		:param target:      The text referred to.
914		:param node:        The reference.
915		:param contnode:    The reference's text.
916		:returns:           A role and link per kind of object the text names unambiguously.
917		"""
918		results: list[tuple[str, nodes.reference]] = []
919		for roleName in self._ROLES:
920			if len(entries := self.FindEntries(roleName, target)) == 1:
921				entry = entries[0]
922				content = nodes.inline(entry.Name, entry.Name, classes=contnode["classes"])
923				reference = make_refnode(builder, fromdocname, entry.docName, entry.anchor, content, entry.title)
924				results.append((f"{self.name}:{roleName}", reference))
925
926		return results
927
928	def get_objects(self) -> Iterator[tuple[str, str, str, str, str, int]]:
929		"""
930		Name the objects of the reports, for the search and for other documentations referring to them.
931
932		An object's name is ``<report ID>:<qualified name>``. A testcase gets a lower search priority than a testsuite,
933		as a report may have thousands of them.
934
935		:returns: A tuple per object: name, display name, type, document, anchor and search priority.
936		"""
937		for roleName, definition in self._ROLES.items():
938			priority = 2 if roleName == "tc" else 1
939			for reportID, entries in self.data[roleName].items():
940				for qualifiedName, entry in entries.items():
941					yield (
942						f"{reportID}:{qualifiedName}", entry.QualifiedName, definition.objectType, entry.docName,
943						entry.anchor or "", priority
944					)
945
946
947@export
948def checkReportConfiguration(sphinx: Sphinx, config: Config) -> None:
949	"""
950	Call-back for Sphinx' ``config-inited`` event, checking the reports' configuration values and loading their settings.
951
952	A mistake is logged as an error rather than stopping the build; a directive naming that report fails on its own.
953
954	:param sphinx: The Sphinx application.
955	:param config: The configuration, after :file:`conf.py` was read.
956	"""
957	from pyTooling.Sphinx.CodeCoverage import CodeCoverageBase
958	from pyTooling.Sphinx.DocCoverage  import DocCoverageBase
959	from pyTooling.Sphinx.Unittest     import UnittestSummary
960
961	checkConfigurations = (
962		CodeCoverageBase.CheckConfiguration,
963		DocCoverageBase.CheckConfiguration,
964		UnittestSummary.CheckConfiguration,
965	)
966
967	for check in checkConfigurations:
968		try:
969			check(sphinx, config)
970		except ReportExtensionError as ex:
971			getLogger(__name__).error(f"Caught {getTypeName(ex)} when checking configuration variables.\n  {ex}")
972
973
974@export
975def readReports(sphinx: Sphinx) -> None:
976	"""
977	Call-back for Sphinx' ``builder-inited`` event, reading the report files, analyzing the documented packages, and
978	registering the reports' pages.
979
980	:param sphinx: The Sphinx application.
981	"""
982	from pyTooling.Sphinx.CodeCoverage      import CodeCoverageBase
983	from pyTooling.Sphinx.CodeCoveragePages import CodeCoverageReportPages
984	from pyTooling.Sphinx.DocCoverage       import DocCoverageBase, DocCoverageReportPages
985	from pyTooling.Sphinx.Unittest          import UnittestSummary
986	from pyTooling.Sphinx.UnittestPages     import UnittestReportPages
987
988	CodeCoverageBase.ReadReports(sphinx)
989	DocCoverageBase.AnalyzePackages(sphinx)
990	UnittestSummary.ReadReports(sphinx)
991	CodeCoverageReportPages.CreatePages(sphinx)
992	DocCoverageReportPages.CreatePages(sphinx)
993	UnittestReportPages.CreatePages(sphinx)
994
995
996@export
997def setup(sphinx: Sphinx) -> dict[str, Any]:
998	"""
999	Register the roles, the nodes, the directives and the domain ``report`` with Sphinx.
1000
1001	The modules are imported here rather than with the package, and none of them imports an optional dependency at
1002	module level - e.g. pyEDAA.Reports is imported only when a report is read - so the extension works without them.
1003
1004	:param sphinx: The Sphinx application to register with.
1005	:returns:      The extension's metadata.
1006	"""
1007	from pyTooling.Sphinx.Abbreviation      import CONFIG_PREFIX as ABBREVIATION_PREFIX, ROLES as ABBREVIATION_ROLES, \
1008	                                               AbbreviationDomain, AbbreviationRole, Abbreviations
1009	from pyTooling.Sphinx.CodeCoverage      import CONFIG_PREFIX as CODE_COVERAGE_PREFIX, CodeCoverage, \
1010	                                               CodeCoverageBase, CodeCoverageLegend, FileCoverage
1011	from pyTooling.Sphinx.CodeCoveragePages import CodeCoverageReportPages
1012	from pyTooling.Sphinx.CondensedClass    import CondensedClass
1013	from pyTooling.Sphinx.DependencyTable   import CONFIG_PREFIX, PROJECT_ROOT_CONFIG, DependencyTable, \
1014	                                               ReplaceFoldedDependencies, prepareEntrypoints, reportBuildTime
1015	from pyTooling.Sphinx.DocCoverage       import CONFIG_PREFIX as DOC_COVERAGE_PREFIX, DocCoverageBase, \
1016	                                               DocCoverageLegend, DocCoverageReportPages, DocStrCoverage
1017	from pyTooling.Sphinx.ExternalLink      import CONFIG_PREFIX as EXTERNAL_LINK_PREFIX, BaseExternalLinkTransform, \
1018	                                               MarkDownloadLinks, MarkExternalLinks, MarkIntersphinxLinks, \
1019	                                               AddDownloadSizes, checkExternalLinkConfiguration
1020	from pyTooling.Sphinx.GitHubMarkdown    import RasterizeShieldsBadges
1021	from pyTooling.Sphinx.Roles             import BREAK_ROLES, PYTHON_CODE_ROLE, STYLE_ROLES, breakRole, \
1022	                                               pythonCodeRole, styleRole
1023	from pyTooling.Sphinx.Shields           import Shields
1024	from pyTooling.Sphinx.Tree              import RemoveTreeLegends, Tree
1025	from pyTooling.Sphinx.UnicodeTable      import RemoveUnicodeGlyphs, UnicodeTable
1026	from pyTooling.Sphinx.Unittest          import CONFIG_PREFIX as UNITTEST_PREFIX, UnittestSummary
1027	from pyTooling.Sphinx.UnittestPages     import UnittestReportPages
1028	from pyTooling.Sphinx.Workaround        import FixLatexTableWidths
1029	from pyTooling.Sphinx.XMLSchemaGraph    import XMLSchemaGraph
1030
1031	for roleName in STYLE_ROLES:
1032		sphinx.add_role(roleName, styleRole)
1033
1034	for roleName in BREAK_ROLES:
1035		sphinx.add_role(roleName, breakRole)
1036
1037	sphinx.add_role(PYTHON_CODE_ROLE, pythonCodeRole)
1038
1039	# the abbreviation roles outside their domain too, as LaTeX' 'acro' names them: ':acs:', not ':abbreviation:acs:'
1040	sphinx.add_domain(AbbreviationDomain)
1041	for roleName in ABBREVIATION_ROLES:
1042		sphinx.add_role(roleName, AbbreviationRole(innernodeclass=nodes.inline, warn_dangling=True))
1043
1044	# the report roles outside their domain too: ':tc:', not only ':report:tc:'
1045	for roleName in REPORT_ROLES:
1046		sphinx.add_role(roleName, ReportRole(innernodeclass=nodes.inline, warn_dangling=True))
1047
1048	sphinx.add_directive("condensed-class", CondensedClass)
1049	sphinx.add_directive("dependency-table", DependencyTable)
1050	sphinx.add_directive("xmlschema-graph", XMLSchemaGraph)
1051	sphinx.add_directive("shields", Shields)
1052	sphinx.add_directive("tree", Tree)
1053	sphinx.add_directive("unicode-table", UnicodeTable)
1054	sphinx.add_directive("abbreviations", Abbreviations)
1055
1056	# Without the domain, these become global directives: 'add_directive' instead of 'add_directive_to_domain'.
1057	reportDirectives = {
1058		"code-coverage":        CodeCoverage,
1059		"code-coverage-legend": CodeCoverageLegend,
1060		"file-coverage":        FileCoverage,
1061		"doc-coverage":         DocStrCoverage,
1062		"doc-coverage-legend":  DocCoverageLegend,
1063		"unittest-summary":     UnittestSummary,
1064	}
1065	sphinx.add_domain(ReportDomain)
1066	for directiveName, directive in reportDirectives.items():
1067		sphinx.add_directive_to_domain(ReportDomain.name, directiveName, directive)
1068
1069	for registeredNode in NODES:
1070		if "latex" in registeredNode:
1071			sphinx.add_node(registeredNode["node"], html=registeredNode["html"], latex=registeredNode["latex"])
1072		else:
1073			sphinx.add_node(registeredNode["node"], html=registeredNode["html"])
1074
1075	# the report tables are put into 'Landscape' nodes, whose LaTeX environment is package 'pdflscape''s
1076	sphinx.add_latex_package("pdflscape")
1077	# the extension's own package, which 'installLaTeXPackage' copies into the build
1078	sphinx.add_latex_package(LATEX_PACKAGE)
1079	sphinx.add_post_transform(FixLatexTableWidths)
1080	sphinx.add_post_transform(RemoveTreeLegends)
1081	sphinx.add_post_transform(ReplaceFoldedDependencies)
1082	sphinx.add_post_transform(RemoveUnicodeGlyphs)
1083	sphinx.add_post_transform(RasterizeShieldsBadges)
1084
1085	sphinx.setup_extension("sphinx.ext.graphviz")
1086	sphinx.setup_extension("sphinx.ext.imgconverter")
1087
1088	for prefix, configValues in (
1089		(CONFIG_PREFIX,        DependencyTable.configValues),
1090		(CODE_COVERAGE_PREFIX, CodeCoverageBase.configValues),
1091		(DOC_COVERAGE_PREFIX,  DocCoverageBase.configValues),
1092		(UNITTEST_PREFIX,      UnittestSummary.configValues),
1093		(EXTERNAL_LINK_PREFIX, BaseExternalLinkTransform.configValues),
1094	):
1095		for configName, (default, rebuild, types) in configValues.items():
1096			sphinx.add_config_value(f"{prefix}_{configName}", default, rebuild, types)
1097
1098	for configName, (default, rebuild, types) in Abbreviations.configValues.items():
1099		sphinx.add_config_value(f"{ABBREVIATION_PREFIX}_{configName}", default, rebuild, types)
1100
1101	sphinx.add_config_value(PROJECT_ROOT_CONFIG, None, "env", (str, Path))
1102
1103	# HTML only: the first marks the links written with their URI, the second the links resolved in between
1104	sphinx.add_post_transform(MarkExternalLinks)
1105	sphinx.add_post_transform(MarkIntersphinxLinks)
1106	sphinx.add_post_transform(MarkDownloadLinks)
1107	sphinx.add_post_transform(AddDownloadSizes)
1108
1109	sphinx.connect("config-inited", extendProlog)
1110	sphinx.connect("config-inited", setLaTeXDefaults)
1111	# after the configuration values above are registered, and before any document is read - a requirements file
1112	# that doesn't exist should end the build here rather than in the middle of a page
1113	sphinx.connect("config-inited", prepareEntrypoints)
1114	sphinx.connect("config-inited", checkReportConfiguration)
1115	sphinx.connect("config-inited", checkExternalLinkConfiguration)
1116	sphinx.connect("builder-inited", readReports)
1117	for reportPages in (CodeCoverageReportPages, DocCoverageReportPages, UnittestReportPages):
1118		sphinx.connect("env-before-read-docs", reportPages.GenerateAll)
1119		sphinx.connect("html-page-context", reportPages.HideSource)
1120	sphinx.connect("build-finished", reportBuildTime)
1121	sphinx.connect("builder-inited", installStaticFiles)
1122	sphinx.connect("builder-inited", installLaTeXPackage)
1123
1124	# The extension's version is the package's, so a second number to keep in step would only ever disagree.
1125	return {"version": __version__, "parallel_read_safe": True, "parallel_write_safe": True}