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

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. 

33 

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. 

38 

39.. seealso:: 

40 

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 

47 

48from docutils import nodes 

49from sphinx.directives import ObjectDescription 

50from sphinx.errors import ExtensionError 

51from sphinx.util.logging import getLogger 

52 

53from pyTooling.Decorators import export 

54from pyTooling.Documentation import DocumentationError 

55 

56 

57_EnumType = TypeVar("_EnumType", bound=Enum) 

58"""Type of an enumeration read from a directive's options.""" 

59 

60 

61@export 

62class SphinxExtensionError(ExtensionError, DocumentationError): 

63 """ 

64 Base-exception of all exceptions raised by :mod:`pyTooling.Documentation.Sphinx`. 

65 

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 """ 

70 

71 

72@export 

73def strip(option: str) -> str: 

74 """ 

75 Option converter removing surrounding whitespace. 

76 

77 :param option: The option's value as it was written. 

78 :returns: The value without surrounding whitespace. 

79 """ 

80 return option.strip() 

81 

82 

83@export 

84def stripAndNormalize(option: str) -> str: 

85 """ 

86 Option converter removing surrounding whitespace and lowering the case. 

87 

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() 

92 

93 

94@export 

95class BaseDirective(ObjectDescription[str]): 

96 """ 

97 Base-class for a directive, offering typed option access and table construction. 

98 

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 """ 

105 

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. 

111 

112 directiveName: str #: Name the directive is invoked by, used in every error message. 

113 

114 def _ParseBooleanOption(self, optionName: str, default: Nullable[bool] = None) -> bool: 

115 """ 

116 Read an option written as ``yes``/``true`` or ``no``/``false``. 

117 

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 

129 

130 raise SphinxExtensionError( 

131 f"{self.directiveName}: Required option '{optionName}' not found for directive." 

132 ) from cause 

133 

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 

138 

139 raise SphinxExtensionError( 

140 f"{self.directiveName}::{optionName}: '{option}' not supported for a boolean value (yes/true, no/false)." 

141 ) 

142 

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. 

146 

147 The pattern defaults to one or more word characters. 

148 

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 

161 

162 raise SphinxExtensionError( 

163 f"{self.directiveName}: Required option '{optionName}' not found for directive." 

164 ) from cause 

165 

166 if re_match(regexp, option): 

167 return option 

168 

169 raise SphinxExtensionError( 

170 f"{self.directiveName}::{optionName}: '{option}' not an accepted value for regexp '{regexp}'." 

171 ) 

172 

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. 

181 

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. 

185 

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 

198 

199 raise SphinxExtensionError( 

200 f"{self.directiveName}: Required option '{optionName}' not found for directive." 

201 ) from cause 

202 

203 identifier = option.lower().replace("-", "_") 

204 

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 

212 

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. 

221 

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)))) 

229 

230 # Setup column specifications 

231 for _, width in columns: 

232 tableGroup += nodes.colspec(colwidth=width) 

233 

234 tableGroup += (tableHeader := nodes.thead()) 

235 tableHeader += (headerRow := nodes.row()) 

236 

237 # Setup header row 

238 for columnTitle, _ in columns: 

239 headerRow += nodes.entry("", nodes.Text(columnTitle)) 

240 

241 return tableGroup 

242 

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. 

251 

252 A column's ``subColumns`` is ``None`` when it spans both header rows, and otherwise holds the 

253 ``(title, width)`` pairs below it. 

254 

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) 

261 

262 # Create table with N columns 

263 table = nodes.table("", identifier=identifier, classes=classes) 

264 table += (tableGroup := nodes.tgroup(cols=columnCount)) 

265 

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) 

273 

274 tableGroup += (tableHeader := nodes.thead()) 

275 tableHeader += (headerRow1 := nodes.row()) 

276 

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 

285 

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)) 

294 

295 return tableGroup 

296 

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. 

305 

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))) 

313 

314 # Setup column specifications 

315 for i, _ in enumerate(columns): 

316 tableGroup += nodes.colspec(classes=[f"col-{i}"]) 

317 

318 tableGroup += (tableHeader := nodes.thead()) 

319 tableHeader += (headerRow := nodes.row()) 

320 

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) 

326 

327 return tableGroup 

328 

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. 

338 

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. 

341 

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) 

354 

355 container += nodes.paragraph(text=message) 

356 

357 return [container]