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

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. 

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: 

50 

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. 

55 

56* the **code role**: 

57 

58 * ``:pycode:`` - highlights inline Python. 

59 

60* the **directives**: 

61 

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. 

69 

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. 

75 

76.. attention:: 

77 

78 This package requires **Python 3.12 or newer**, because it requires Sphinx 9.1 and Sphinx 9.1 does. 

79 

80.. seealso:: 

81 

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" 

94 

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 

100 

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 

106 

107from pyTooling.Common import readResourceFile 

108from pyTooling.Decorators import export 

109from pyTooling.Documentation import DocumentationError 

110 

111from pyTooling.Sphinx import Resources as SphinxResources 

112 

113 

114__all__ = ["STYLESHEET", "SUBSTITUTIONS"] 

115 

116#: Name of the stylesheet, in :mod:`pyTooling.Sphinx.Resources`. 

117STYLESHEET = "pyTooling.css" 

118 

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: 

126 

127.. |br| replace:: :br:`.` 

128 

129.. |hr| replace:: :hr:`.` 

130""" 

131 

132 

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

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

135 

136 

137@export 

138class SphinxExtensionError(ExtensionError, DocumentationError): 

139 """ 

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

141 

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

146 

147 

148@export 

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

150 """ 

151 Option converter removing surrounding whitespace. 

152 

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

154 :returns: The value without surrounding whitespace. 

155 """ 

156 return option.strip() 

157 

158 

159@export 

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

161 """ 

162 Option converter removing surrounding whitespace and lowering the case. 

163 

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

168 

169 

170@export 

171class BaseDirective(ObjectDescription[str]): 

172 """ 

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

174 

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

181 

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. 

187 

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

189 

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

191 """ 

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

193 

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 

205 

206 raise SphinxExtensionError( 

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

208 ) from cause 

209 

210 if option in ("yes", "true"): 

211 return True 

212 elif option in ("no", "false"): 

213 return False 

214 

215 raise SphinxExtensionError( 

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

217 ) 

218 

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. 

222 

223 The pattern defaults to one or more word characters. 

224 

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 

237 

238 raise SphinxExtensionError( 

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

240 ) from cause 

241 

242 if re_match(regexp, option): 

243 return option 

244 

245 raise SphinxExtensionError( 

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

247 ) 

248 

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. 

257 

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. 

261 

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 

274 

275 raise SphinxExtensionError( 

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

277 ) from cause 

278 

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

280 

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 

288 

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. 

297 

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

305 

306 # Setup column specifications 

307 for _, width in columns: 

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

309 

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

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

312 

313 # Setup header row 

314 for columnTitle, _ in columns: 

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

316 

317 return tableGroup 

318 

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. 

327 

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

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

330 

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) 

337 

338 # Create table with N columns 

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

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

341 

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) 

349 

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

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

352 

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 

361 

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

370 

371 return tableGroup 

372 

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. 

381 

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

389 

390 # Setup column specifications 

391 for i, _ in enumerate(columns): 

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

393 

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

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

396 

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) 

402 

403 return tableGroup 

404 

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. 

414 

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. 

417 

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) 

430 

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

432 

433 return [container] 

434 

435 

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. 

440 

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. 

443 

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

449 

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) 

454 

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

460 

461 stylesheet.write_text(content, encoding="utf-8") 

462 

463 

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``. 

468 

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. 

472 

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 

477 

478 

479@export 

480def setup(sphinx: Sphinx) -> dict[str, Any]: 

481 """ 

482 Register the roles, the node and the directives with Sphinx. 

483 

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 

493 

494 for roleName in STYLE_ROLES: 

495 sphinx.add_role(roleName, styleRole) 

496 

497 for roleName in BREAK_ROLES: 

498 sphinx.add_role(roleName, breakRole) 

499 

500 sphinx.add_role(PYTHON_CODE_ROLE, pythonCodeRole) 

501 

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) 

506 

507 sphinx.setup_extension("sphinx.ext.graphviz") 

508 

509 for configName, (default, rebuild, types) in DependencyTable.configValues.items(): 

510 sphinx.add_config_value(f"{CONFIG_PREFIX}_{configName}", default, rebuild, types) 

511 

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) 

518 

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}