Coverage for pyTooling/Documentation/Sphinx/Directives.py: 45%
115 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-03 23:02 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-03 23:02 +0000
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 base-class for Sphinx directives, wrapping the parts of docutils a directive keeps re-deriving.
34Sphinx and docutils present their options as an untyped mapping and their tables as a tree of nodes assembled by
35hand. :class:`BaseDirective` puts a typed, validating layer over both: an option is *read* with a method that
36returns the type asked for and raises :exc:`SphinxExtensionError` naming the directive and the option when it
37can't, and a table header is *described* by its columns rather than built node by node.
39.. seealso::
41 :mod:`pyTooling.Documentation.Sphinx`
42 |rarr| The extension this belongs to, and what else it brings.
43"""
44from enum import Enum
45from re import match as re_match
46from typing import Optional as Nullable, TypeVar
48from docutils import nodes
49from sphinx.directives import ObjectDescription
50from sphinx.errors import ExtensionError
51from sphinx.util.logging import getLogger
53from pyTooling.Decorators import export
54from pyTooling.Documentation import DocumentationError
57_EnumType = TypeVar("_EnumType", bound=Enum)
58"""Type of an enumeration read from a directive's options."""
61@export
62class SphinxExtensionError(ExtensionError, DocumentationError):
63 """
64 Base-exception of all exceptions raised by :mod:`pyTooling.Documentation.Sphinx`.
66 It derives from **both** hierarchies on purpose: :exc:`~sphinx.errors.ExtensionError` is what Sphinx catches and
67 reports with the position of the directive, and :exc:`~pyTooling.Documentation.DocumentationError` is what a
68 caller of pyTooling catches. Neither would be enough alone.
69 """
72@export
73def strip(option: str) -> str:
74 """
75 Option converter removing surrounding whitespace.
77 :param option: The option's value as it was written.
78 :returns: The value without surrounding whitespace.
79 """
80 return option.strip()
83@export
84def stripAndNormalize(option: str) -> str:
85 """
86 Option converter removing surrounding whitespace and lowering the case.
88 :param option: The option's value as it was written.
89 :returns: The value without surrounding whitespace, in lower case.
90 """
91 return option.strip().lower()
94@export
95class BaseDirective(ObjectDescription[str]):
96 """
97 Base-class for a directive, offering typed option access and table construction.
99 A derived class sets :attr:`directiveName` - which is what the error messages name - and declares
100 ``option_spec`` as any directive does. What it gets in return is a ``_Parse***Option`` per type instead of
101 reaching into :attr:`options` and validating by hand, and a ``_Create***TableHeader`` per table shape instead of
102 assembling :class:`~docutils.nodes.tgroup`, :class:`~docutils.nodes.colspec`, :class:`~docutils.nodes.thead`
103 and :class:`~docutils.nodes.row` in the right order.
104 """
106 has_content = False #: A boolean; ``True`` if content is allowed.
107 required_arguments = 0 #: Number of required directive arguments.
108 optional_arguments = 0 #: Number of optional arguments after the required ones.
109 final_argument_whitespace = False #: A boolean; ``True`` if the last argument may contain spaces.
110 option_spec = {} #: Mapping of option names to validator functions.
112 directiveName: str #: Name the directive is invoked by, used in every error message.
114 def _ParseBooleanOption(self, optionName: str, default: Nullable[bool] = None) -> bool:
115 """
116 Read an option written as ``yes``/``true`` or ``no``/``false``.
118 :param optionName: Name of the option to read.
119 :param default: Optional, the value to return when the option wasn't given.
120 :returns: The option's value.
121 :raises SphinxExtensionError: If the option wasn't given and has no default.
122 :raises SphinxExtensionError: If the option's value is neither of the two accepted spellings.
123 """
124 try:
125 option = self.options[optionName]
126 except KeyError as cause:
127 if default is not None: 127 ↛ 130line 127 didn't jump to line 130 because the condition on line 127 was always true
128 return default
130 raise SphinxExtensionError(
131 f"{self.directiveName}: Required option '{optionName}' not found for directive."
132 ) from cause
134 if option in ("yes", "true"): 134 ↛ 135line 134 didn't jump to line 135 because the condition on line 134 was never true
135 return True
136 elif option in ("no", "false"):
137 return False
139 raise SphinxExtensionError(
140 f"{self.directiveName}::{optionName}: '{option}' not supported for a boolean value (yes/true, no/false)."
141 )
143 def _ParseStringOption(self, optionName: str, default: Nullable[str] = None, regexp: str = "\\w+") -> str:
144 """
145 Read an option that has to match a regular expression.
147 The pattern defaults to one or more word characters.
149 :param optionName: Name of the option to read.
150 :param default: Optional, the value to return when the option wasn't given.
151 :param regexp: Optional, the pattern the value has to match.
152 :returns: The option's value.
153 :raises SphinxExtensionError: If the option wasn't given and has no default.
154 :raises SphinxExtensionError: If the option's value doesn't match the pattern.
155 """
156 try:
157 option: str = self.options[optionName]
158 except KeyError as cause:
159 if default is not None: 159 ↛ 162line 159 didn't jump to line 162 because the condition on line 159 was always true
160 return default
162 raise SphinxExtensionError(
163 f"{self.directiveName}: Required option '{optionName}' not found for directive."
164 ) from cause
166 if re_match(regexp, option):
167 return option
169 raise SphinxExtensionError(
170 f"{self.directiveName}::{optionName}: '{option}' not an accepted value for regexp '{regexp}'."
171 )
173 def _ParseEnumOption(
174 self,
175 optionName: str,
176 enumType: type[_EnumType],
177 default: Nullable[_EnumType] = None
178 ) -> _EnumType:
179 """
180 Read an option naming a member of an enumeration.
182 The written value is lowered and its dashes become underscores, so ``horizontal-table`` in a document selects
183 the ``horizontal_table`` member - a document reads in the spelling documents use, and the enumeration keeps
184 the spelling Python uses.
186 :param optionName: Name of the option to read.
187 :param enumType: The enumeration whose members the value is looked up in.
188 :param default: Optional, the member to return when the option wasn't given.
189 :returns: The named member of the enumeration.
190 :raises SphinxExtensionError: If the option wasn't given and has no default.
191 :raises SphinxExtensionError: If the value names no member of the enumeration.
192 """
193 try:
194 option: str = self.options[optionName]
195 except KeyError as cause:
196 if default is not None:
197 return default
199 raise SphinxExtensionError(
200 f"{self.directiveName}: Required option '{optionName}' not found for directive."
201 ) from cause
203 identifier = option.lower().replace("-", "_")
205 try:
206 return enumType[identifier]
207 except KeyError as cause:
208 raise SphinxExtensionError(
209 f"{self.directiveName}::{optionName}: Value '{option}' (transformed: '{identifier}') is not a valid "
210 f"member of '{enumType.__name__}'."
211 ) from cause
213 def _CreateSingleRowTableHeader(
214 self,
215 columns: list[tuple[str, Nullable[int]]],
216 identifier: str,
217 classes: list[str]
218 ) -> nodes.tgroup:
219 """
220 Create a table with a single header row.
222 :param columns: One ``(title, width)`` pair per column; a width of ``None`` leaves it to the writer.
223 :param identifier: Identifier of the table.
224 :param classes: CSS classes to put on the table.
225 :returns: The table's column group, with the header row already in it.
226 """
227 table = nodes.table("", identifier=identifier, classes=classes)
228 table += (tableGroup := nodes.tgroup(cols=(len(columns))))
230 # Setup column specifications
231 for _, width in columns:
232 tableGroup += nodes.colspec(colwidth=width)
234 tableGroup += (tableHeader := nodes.thead())
235 tableHeader += (headerRow := nodes.row())
237 # Setup header row
238 for columnTitle, _ in columns:
239 headerRow += nodes.entry("", nodes.Text(columnTitle))
241 return tableGroup
243 def _CreateDoubleRowTableHeader(
244 self,
245 columns: list[tuple[str, Nullable[list[tuple[str, int]]], Nullable[int]]],
246 identifier: str,
247 classes: list[str]
248 ) -> nodes.tgroup:
249 """
250 Create a table whose header spans two rows, so a column can group sub-columns.
252 A column's ``subColumns`` is ``None`` when it spans both header rows, and otherwise holds the
253 ``(title, width)`` pairs below it.
255 :param columns: One ``(title, subColumns, width)`` triple per column.
256 :param identifier: Identifier of the table.
257 :param classes: CSS classes to put on the table.
258 :returns: The table's column group, with both header rows already in it.
259 """
260 columnCount = sum(len(groupColumn[1]) if groupColumn[1] is not None else 1 for groupColumn in columns)
262 # Create table with N columns
263 table = nodes.table("", identifier=identifier, classes=classes)
264 table += (tableGroup := nodes.tgroup(cols=columnCount))
266 # Setup column specifications
267 for _, more, width in columns:
268 if more is None:
269 tableGroup += nodes.colspec(colwidth=width)
270 else:
271 for _, width in more:
272 tableGroup += nodes.colspec(colwidth=width)
274 tableGroup += (tableHeader := nodes.thead())
275 tableHeader += (headerRow1 := nodes.row())
277 # Setup primary header row
278 for columnTitle, more, _ in columns:
279 if more is None:
280 headerRow1 += nodes.entry("", nodes.Text(columnTitle), morerows=1)
281 else:
282 headerRow1 += nodes.entry("", nodes.Text(columnTitle), morecols=(morecols := len(more) - 1))
283 for _ in range(morecols):
284 headerRow1 += None
286 # Setup secondary header row
287 tableHeader += (headerRow2 := nodes.row())
288 for columnTitle, more, _ in columns:
289 if more is None:
290 headerRow2 += None
291 else:
292 for columnTitle, _ in more:
293 headerRow2 += nodes.entry("", nodes.Text(columnTitle))
295 return tableGroup
297 def _CreateRotatedTableHeader(
298 self,
299 columns: list[tuple[str, Nullable[list[str]]]],
300 identifier: str,
301 classes: list[str]
302 ) -> nodes.tgroup:
303 """
304 Create a table whose header titles are rotated, for many narrow columns.
306 :param columns: One ``(title, classes)`` pair per column; the classes are put on the header cell.
307 :param identifier: Identifier of the table.
308 :param classes: CSS classes to put on the table.
309 :returns: The table's column group, with the header row already in it.
310 """
311 table = nodes.table("", identifier=identifier, classes=classes)
312 table += (tableGroup := nodes.tgroup(cols=len(columns)))
314 # Setup column specifications
315 for i, _ in enumerate(columns):
316 tableGroup += nodes.colspec(classes=[f"col-{i}"])
318 tableGroup += (tableHeader := nodes.thead())
319 tableHeader += (headerRow := nodes.row())
321 # Setup header row
322 for columnTitle, columnClasses in columns:
323 span = nodes.inline("", text=columnTitle)
324 div = nodes.container("", span)
325 headerRow += nodes.entry("", div, classes=[] if columnClasses is None else columnClasses)
327 return tableGroup
329 def _internalError(
330 self,
331 container: nodes.container,
332 location: str,
333 message: str,
334 exception: Exception
335 ) -> list[nodes.Node]:
336 """
337 Report an exception a directive couldn't recover from, in the log **and** on the page.
339 A directive that fails silently leaves a hole in the documentation that nobody notices. This puts the message
340 where a reader sees it and the traceback where a maintainer does.
342 :param container: The container the message is put into.
343 :param location: Name of the logger, which is what the log line is attributed to.
344 :param message: What went wrong, in one sentence.
345 :param exception: The exception that was caught.
346 :returns: The container, as the list a directive's ``run`` returns.
347 """
348 logger = getLogger(location)
349 logger.error(f"{message}")
350 logger.error(f" {exception.__class__.__name__}: {exception}")
351 if exception.__cause__ is not None:
352 logger.error(f" {exception.__cause__.__class__.__name__}: {exception.__cause__}")
353 logger.exception(exception)
355 container += nodes.paragraph(text=message)
357 return [container]