__init__.py
Summary
- Lines:
376 of 411 covered (91.5%)
- Branches:
98 of 118 taken (83.1%), 12 lines partially
- Coverage:
89.6%
- Directory:
- 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}