__init__.py

Summary

Lines:

376 of 411 covered (91.5%)

Branches:

98 of 118 taken (83.1%), 12 lines partially

Coverage:

89.6%

Directory:

Sphinx

Missed lines:

344, 349, 376, 383, 413, 421-422, 483, 494, 504, 525-541, 604, 744, 808, 871, 898-900, 922-930

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