Coverage for pyTooling/Sphinx/__init__.py: 43%
173 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-04 01:28 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-04 01:28 +0000
1# ==================================================================================================================== #
2# _____ _ _ ____ _ _ #
3# _ __ _ |_ _|__ ___ | (_)_ __ __ _ / ___| _ __ | |__ (_)_ __ __ __ #
4# | '_ \| | | || |/ _ \ / _ \| | | '_ \ / _` | \___ \| '_ \| '_ \| | '_ \\ \/ / #
5# | |_) | |_| || | (_) | (_) | | | | | | (_| |_ ___) | |_) | | | | | | | |> < #
6# | .__/ \__, ||_|\___/ \___/|_|_|_| |_|\__, (_)____/| .__/|_| |_|_|_| |_/_/\_\ #
7# |_| |___/ |___/ |_| #
8# ==================================================================================================================== #
9# Authors: #
10# Patrick Lehmann #
11# #
12# License: #
13# ==================================================================================================================== #
14# Copyright 2026-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.
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:
38.. code-block:: Python
40 # doc/conf.py
41 extensions = [
42 ...,
43 "pyTooling.Sphinx",
44 ]
46.. rubric:: What it registers
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:
51 * ``:bolditalic:``, ``:underline:``, ``:strike:`` and ``:xlarge:`` - weight, decoration and size;
52 * ``:red:``, ``:green:``, ``:blue:`` and ``:purple:`` - colour, each from a CSS custom property a project can
53 redefine;
54 * ``:deletion:`` and ``:addition:`` - the two a diff needs.
56* the **code role**:
58 * ``:pycode:`` - highlights inline Python.
60* the **directives**:
62 * :rst:dir:`condensed-class` - renders a class' public interface from its source;
63 * :rst:dir:`dependency-table` - renders a project's dependencies from its requirements files, which
64 :file:`conf.py` declares under ``pyTooling_Dependency_Requirements``;
65 * :rst:dir:`xmlschema-graph` - draws an XML schema as a Graphviz graph. It sets up :mod:`sphinx.ext.graphviz`
66 itself, and needs :mod:`xmlschema` only in a project that uses it.
67 * :rst:dir:`shields` - renders a project's badges from shields.io, in rows, from the coordinates its options
68 state: the GitHub repository, the PyPI package, the licenses, the workflow and the documentation's URL.
70Two classes aren't registered, because they are base-classes for a project's own directives:
71:class:`~pyTooling.Sphinx.BaseDirective` offers typed option access and table construction
72over the untyped mapping and the hand-assembled node trees docutils presents, and
73:class:`~pyTooling.Sphinx.SchemaGraph.SchemaGraph` draws a schema file named as a directive's
74argument, leaving only the reading of that schema to a derived class.
76.. attention::
78 This package requires **Python 3.12 or newer**, because it requires Sphinx 9.1 and Sphinx 9.1 does.
80.. seealso::
82 :mod:`pyTooling.Documentation`
83 |rarr| The doc-string helpers, which need no Sphinx.
84"""
85__author__ = "Patrick Lehmann"
86__email__ = "Paebbels@gmail.com"
87__copyright__ = "2026-2026, Patrick Lehmann"
88__license__ = "Apache License, Version 2.0"
89__version__ = "0.1.0"
90__keywords__ = ["Sphinx", "Sphinx Extension", "Documentation", "Directive", "Role", "Domain", "docutils"]
91__project_url__ = "https://github.com/pyTooling/pyTooling.Sphinx"
92__documentation_url__ = "https://pyTooling.github.io/pyTooling.Sphinx"
93__issue_tracker_url__ = "https://GitHub.com/pyTooling/pyTooling.Sphinx/issues"
95from enum import Enum
96from hashlib import md5
97from pathlib import Path
98from re import match as re_match
99from typing import Any, Optional as Nullable, TypeVar
101from docutils import nodes
102from sphinx.application import Sphinx
103from sphinx.directives import ObjectDescription
104from sphinx.errors import ExtensionError
105from sphinx.util.logging import getLogger
107from pyTooling.Common import readResourceFile
108from pyTooling.Decorators import export
109from pyTooling.Documentation import DocumentationError
111from pyTooling.Sphinx import Resources as SphinxResources
114__all__ = ["STYLESHEET", "SUBSTITUTIONS"]
116#: Name of the stylesheet, in :mod:`pyTooling.Sphinx.Resources`.
117STYLESHEET = "pyTooling.css"
119#: Substitutions that have to stay substitutions, because ``|br|`` is written as one in every project.
120#:
121#: ``|br|`` and ``|hr|`` delegate to the ``:br:`` and ``:hr:`` roles rather than writing HTML themselves, so they
122#: reach every output format instead of only HTML - see :func:`~pyTooling.Sphinx.Roles.breakRole`.
123SUBSTITUTIONS = """
124.. |degree| unicode:: U+00B0
125 :trim:
127.. |br| replace:: :br:`.`
129.. |hr| replace:: :hr:`.`
130"""
133_EnumType = TypeVar("_EnumType", bound=Enum)
134"""Type of an enumeration read from a directive's options."""
137@export
138class SphinxExtensionError(ExtensionError, DocumentationError):
139 """
140 Base-exception of all exceptions raised by :mod:`pyTooling.Sphinx`.
142 It derives from **both** hierarchies on purpose: :exc:`~sphinx.errors.ExtensionError` is what Sphinx catches and
143 reports with the position of the directive, and :exc:`~pyTooling.Documentation.DocumentationError` is what a
144 caller of pyTooling catches. Neither would be enough alone.
145 """
148@export
149def strip(option: str) -> str:
150 """
151 Option converter removing surrounding whitespace.
153 :param option: The option's value as it was written.
154 :returns: The value without surrounding whitespace.
155 """
156 return option.strip()
159@export
160def stripAndNormalize(option: str) -> str:
161 """
162 Option converter removing surrounding whitespace and lowering the case.
164 :param option: The option's value as it was written.
165 :returns: The value without surrounding whitespace, in lower case.
166 """
167 return option.strip().lower()
170@export
171class BaseDirective(ObjectDescription[str]):
172 """
173 Base-class for a directive, offering typed option access and table construction.
175 A derived class sets :attr:`directiveName` - which is what the error messages name - and declares
176 ``option_spec`` as any directive does. What it gets in return is a ``_Parse***Option`` per type instead of
177 reaching into :attr:`options` and validating by hand, and a ``_Create***TableHeader`` per table shape instead of
178 assembling :class:`~docutils.nodes.tgroup`, :class:`~docutils.nodes.colspec`, :class:`~docutils.nodes.thead`
179 and :class:`~docutils.nodes.row` in the right order.
180 """
182 has_content = False #: A boolean; ``True`` if content is allowed.
183 required_arguments = 0 #: Number of required directive arguments.
184 optional_arguments = 0 #: Number of optional arguments after the required ones.
185 final_argument_whitespace = False #: A boolean; ``True`` if the last argument may contain spaces.
186 option_spec = {} #: Mapping of option names to validator functions.
188 directiveName: str #: Name the directive is invoked by, used in every error message.
190 def _ParseBooleanOption(self, optionName: str, default: Nullable[bool] = None) -> bool:
191 """
192 Read an option written as ``yes``/``true`` or ``no``/``false``.
194 :param optionName: Name of the option to read.
195 :param default: Optional, the value to return when the option wasn't given.
196 :returns: The option's value.
197 :raises SphinxExtensionError: If the option wasn't given and has no default.
198 :raises SphinxExtensionError: If the option's value is neither of the two accepted spellings.
199 """
200 try:
201 option = self.options[optionName]
202 except KeyError as cause:
203 if default is not None:
204 return default
206 raise SphinxExtensionError(
207 f"{self.directiveName}: Required option '{optionName}' not found for directive."
208 ) from cause
210 if option in ("yes", "true"):
211 return True
212 elif option in ("no", "false"):
213 return False
215 raise SphinxExtensionError(
216 f"{self.directiveName}::{optionName}: '{option}' not supported for a boolean value (yes/true, no/false)."
217 )
219 def _ParseStringOption(self, optionName: str, default: Nullable[str] = None, regexp: str = "\\w+") -> str:
220 """
221 Read an option that has to match a regular expression.
223 The pattern defaults to one or more word characters.
225 :param optionName: Name of the option to read.
226 :param default: Optional, the value to return when the option wasn't given.
227 :param regexp: Optional, the pattern the value has to match.
228 :returns: The option's value.
229 :raises SphinxExtensionError: If the option wasn't given and has no default.
230 :raises SphinxExtensionError: If the option's value doesn't match the pattern.
231 """
232 try:
233 option: str = self.options[optionName]
234 except KeyError as cause:
235 if default is not None:
236 return default
238 raise SphinxExtensionError(
239 f"{self.directiveName}: Required option '{optionName}' not found for directive."
240 ) from cause
242 if re_match(regexp, option):
243 return option
245 raise SphinxExtensionError(
246 f"{self.directiveName}::{optionName}: '{option}' not an accepted value for regexp '{regexp}'."
247 )
249 def _ParseEnumOption(
250 self,
251 optionName: str,
252 enumType: type[_EnumType],
253 default: Nullable[_EnumType] = None
254 ) -> _EnumType:
255 """
256 Read an option naming a member of an enumeration.
258 The written value is lowered and its dashes become underscores, so ``horizontal-table`` in a document selects
259 the ``horizontal_table`` member - a document reads in the spelling documents use, and the enumeration keeps
260 the spelling Python uses.
262 :param optionName: Name of the option to read.
263 :param enumType: The enumeration whose members the value is looked up in.
264 :param default: Optional, the member to return when the option wasn't given.
265 :returns: The named member of the enumeration.
266 :raises SphinxExtensionError: If the option wasn't given and has no default.
267 :raises SphinxExtensionError: If the value names no member of the enumeration.
268 """
269 try:
270 option: str = self.options[optionName]
271 except KeyError as cause:
272 if default is not None:
273 return default
275 raise SphinxExtensionError(
276 f"{self.directiveName}: Required option '{optionName}' not found for directive."
277 ) from cause
279 identifier = option.lower().replace("-", "_")
281 try:
282 return enumType[identifier]
283 except KeyError as cause:
284 raise SphinxExtensionError(
285 f"{self.directiveName}::{optionName}: Value '{option}' (transformed: '{identifier}') is not a valid "
286 f"member of '{enumType.__name__}'."
287 ) from cause
289 def _CreateSingleRowTableHeader(
290 self,
291 columns: list[tuple[str, Nullable[int]]],
292 identifier: str,
293 classes: list[str]
294 ) -> nodes.tgroup:
295 """
296 Create a table with a single header row.
298 :param columns: One ``(title, width)`` pair per column; a width of ``None`` leaves it to the writer.
299 :param identifier: Identifier of the table.
300 :param classes: CSS classes to put on the table.
301 :returns: The table's column group, with the header row already in it.
302 """
303 table = nodes.table("", identifier=identifier, classes=classes)
304 table += (tableGroup := nodes.tgroup(cols=(len(columns))))
306 # Setup column specifications
307 for _, width in columns:
308 tableGroup += nodes.colspec(colwidth=width)
310 tableGroup += (tableHeader := nodes.thead())
311 tableHeader += (headerRow := nodes.row())
313 # Setup header row
314 for columnTitle, _ in columns:
315 headerRow += nodes.entry("", nodes.Text(columnTitle))
317 return tableGroup
319 def _CreateDoubleRowTableHeader(
320 self,
321 columns: list[tuple[str, Nullable[list[tuple[str, int]]], Nullable[int]]],
322 identifier: str,
323 classes: list[str]
324 ) -> nodes.tgroup:
325 """
326 Create a table whose header spans two rows, so a column can group sub-columns.
328 A column's ``subColumns`` is ``None`` when it spans both header rows, and otherwise holds the
329 ``(title, width)`` pairs below it.
331 :param columns: One ``(title, subColumns, width)`` triple per column.
332 :param identifier: Identifier of the table.
333 :param classes: CSS classes to put on the table.
334 :returns: The table's column group, with both header rows already in it.
335 """
336 columnCount = sum(len(groupColumn[1]) if groupColumn[1] is not None else 1 for groupColumn in columns)
338 # Create table with N columns
339 table = nodes.table("", identifier=identifier, classes=classes)
340 table += (tableGroup := nodes.tgroup(cols=columnCount))
342 # Setup column specifications
343 for _, more, width in columns:
344 if more is None:
345 tableGroup += nodes.colspec(colwidth=width)
346 else:
347 for _, width in more:
348 tableGroup += nodes.colspec(colwidth=width)
350 tableGroup += (tableHeader := nodes.thead())
351 tableHeader += (headerRow1 := nodes.row())
353 # Setup primary header row
354 for columnTitle, more, _ in columns:
355 if more is None:
356 headerRow1 += nodes.entry("", nodes.Text(columnTitle), morerows=1)
357 else:
358 headerRow1 += nodes.entry("", nodes.Text(columnTitle), morecols=(morecols := len(more) - 1))
359 for _ in range(morecols):
360 headerRow1 += None
362 # Setup secondary header row
363 tableHeader += (headerRow2 := nodes.row())
364 for columnTitle, more, _ in columns:
365 if more is None:
366 headerRow2 += None
367 else:
368 for columnTitle, _ in more:
369 headerRow2 += nodes.entry("", nodes.Text(columnTitle))
371 return tableGroup
373 def _CreateRotatedTableHeader(
374 self,
375 columns: list[tuple[str, Nullable[list[str]]]],
376 identifier: str,
377 classes: list[str]
378 ) -> nodes.tgroup:
379 """
380 Create a table whose header titles are rotated, for many narrow columns.
382 :param columns: One ``(title, classes)`` pair per column; the classes are put on the header cell.
383 :param identifier: Identifier of the table.
384 :param classes: CSS classes to put on the table.
385 :returns: The table's column group, with the header row already in it.
386 """
387 table = nodes.table("", identifier=identifier, classes=classes)
388 table += (tableGroup := nodes.tgroup(cols=len(columns)))
390 # Setup column specifications
391 for i, _ in enumerate(columns):
392 tableGroup += nodes.colspec(classes=[f"col-{i}"])
394 tableGroup += (tableHeader := nodes.thead())
395 tableHeader += (headerRow := nodes.row())
397 # Setup header row
398 for columnTitle, columnClasses in columns:
399 span = nodes.inline("", text=columnTitle)
400 div = nodes.container("", span)
401 headerRow += nodes.entry("", div, classes=[] if columnClasses is None else columnClasses)
403 return tableGroup
405 def _internalError(
406 self,
407 container: nodes.container,
408 location: str,
409 message: str,
410 exception: Exception
411 ) -> list[nodes.Node]:
412 """
413 Report an exception a directive couldn't recover from, in the log **and** on the page.
415 A directive that fails silently leaves a hole in the documentation that nobody notices. This puts the message
416 where a reader sees it and the traceback where a maintainer does.
418 :param container: The container the message is put into.
419 :param location: Name of the logger, which is what the log line is attributed to.
420 :param message: What went wrong, in one sentence.
421 :param exception: The exception that was caught.
422 :returns: The container, as the list a directive's ``run`` returns.
423 """
424 logger = getLogger(location)
425 logger.error(f"{message}")
426 logger.error(f" {exception.__class__.__name__}: {exception}")
427 if exception.__cause__ is not None:
428 logger.error(f" {exception.__cause__.__class__.__name__}: {exception.__cause__}")
429 logger.exception(exception)
431 container += nodes.paragraph(text=message)
433 return [container]
436@export
437def installStylesheet(sphinx: Sphinx) -> None:
438 """
439 Call-back for Sphinx' ``builder-inited`` event, writing the stylesheet into the build and linking it.
441 The file is named by the hash of its content, so a browser re-reads it when the styles change and re-uses it when
442 they don't. Older copies are removed when the content changed.
444 :param sphinx: The Sphinx application.
445 """
446 staticDirectory = (Path(sphinx.outdir) / "_pyTooling_static").resolve()
447 staticDirectory.mkdir(exist_ok=True)
448 sphinx.config.html_static_path.append(str(staticDirectory))
450 content = readResourceFile(SphinxResources, STYLESHEET)
451 digest = md5(content.encode("utf-8")).hexdigest() # nosec B324 - a cache-busting name, not a signature
452 stylesheet = staticDirectory / f"pyTooling.{digest}.css"
453 sphinx.add_css_file(stylesheet.name)
455 if not stylesheet.exists(): 455 ↛ exitline 455 didn't return from function 'installStylesheet' because the condition on line 455 was always true
456 # Only this package's own copies - the directory is on 'html_static_path', so another extension may
457 # have written its stylesheet beside ours.
458 for outdated in staticDirectory.glob("pyTooling.*.css"): 458 ↛ 459line 458 didn't jump to line 459 because the loop on line 458 never started
459 outdated.unlink()
461 stylesheet.write_text(content, encoding="utf-8")
464@export
465def extendProlog(sphinx: Sphinx, config: Any) -> None:
466 """
467 Call-back for Sphinx' ``config-inited`` event, appending the shared substitutions to ``rst_prolog``.
469 A role can be registered; a **substitution** cannot - ``|br|`` is substitution syntax, and every project writes
470 it that way already. Appending them here is what lets a project delete them from its own prolog without changing
471 a single document.
473 :param sphinx: The Sphinx application.
474 :param config: The configuration, after :file:`conf.py` was read.
475 """
476 config.rst_prolog = (config.rst_prolog or "") + SUBSTITUTIONS
479@export
480def setup(sphinx: Sphinx) -> dict[str, Any]:
481 """
482 Register the roles, the node and the directives with Sphinx.
484 :param sphinx: The Sphinx application to register with.
485 :returns: The extension's metadata.
486 """
487 from pyTooling.Sphinx.CondensedClass import CondensedClass
488 from pyTooling.Sphinx.DependencyTable import CONFIG_PREFIX, DependencyTable, prepareEntrypoints, reportBuildTime
489 from pyTooling.Sphinx.Roles import BREAK_ROLES, PYTHON_CODE_ROLE, STYLE_ROLES
490 from pyTooling.Sphinx.Roles import breakRole, pythonCodeRole, styleRole
491 from pyTooling.Sphinx.Shields import Shields
492 from pyTooling.Sphinx.XMLSchemaGraph import XMLSchemaGraph
494 for roleName in STYLE_ROLES:
495 sphinx.add_role(roleName, styleRole)
497 for roleName in BREAK_ROLES:
498 sphinx.add_role(roleName, breakRole)
500 sphinx.add_role(PYTHON_CODE_ROLE, pythonCodeRole)
502 sphinx.add_directive("condensed-class", CondensedClass)
503 sphinx.add_directive("dependency-table", DependencyTable)
504 sphinx.add_directive("xmlschema-graph", XMLSchemaGraph)
505 sphinx.add_directive("shields", Shields)
507 sphinx.setup_extension("sphinx.ext.graphviz")
509 for configName, (default, rebuild, types) in DependencyTable.configValues.items():
510 sphinx.add_config_value(f"{CONFIG_PREFIX}_{configName}", default, rebuild, types)
512 sphinx.connect("config-inited", extendProlog)
513 # after the configuration values above are registered, and before any document is read - a requirements file
514 # that doesn't exist should end the build here rather than in the middle of a page
515 sphinx.connect("config-inited", prepareEntrypoints)
516 sphinx.connect("build-finished", reportBuildTime)
517 sphinx.connect("builder-inited", installStylesheet)
519 # The extension's version is the package's, so a second number to keep in step would only ever disagree.
520 return {"version": __version__, "parallel_read_safe": True, "parallel_write_safe": True}