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