Coverage for pyTooling/Documentation/Sphinx/Shields.py: 89%

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

31A Sphinx directive rendering a project's badges. 

32 

33A landing page shows where a project lives, how it is licensed, whether it builds and where it is published, as a 

34few rows of `shields.io <https://shields.io/>`__ badges. The directive states the project's coordinates as options 

35and the badges as its content: 

36 

37.. code-block:: ReST 

38 

39 .. shields:: 

40 :github: pyTooling/pyTooling 

41 :pypi: pyTooling 

42 :codacy: 08ef744c0b70490289712b02a7a4cebe 

43 :source-license: github:LICENSE.md 

44 :documentation-license: CC-BY-4.0 github:doc/License.rst 

45 :github-action: Pipeline.yml@main 

46 :documentation: github-pages 

47 

48 github, src-license, ghp-doc, doc-license 

49 pypi-tag, pypi-status, pypi-python 

50 github-action, lib-status, codacy-quality, codacy-coverage, codecov-coverage 

51 

52**The content is the layout**: badges appear in the order written, and a new line starts a new row. A badge needs 

53only the options its URLs are made of - :data:`SHIELDS` names them per badge - so a project states what its badges 

54use and nothing else. 

55 

56.. rubric:: Links 

57 

58``:source-license:`` and ``:documentation-license:`` link to where the license is written: 

59 

60* ``github:<path>`` is a file in the repository ``:github:`` names, on its default branch. 

61* ``http://…`` or ``https://…`` is used as written. 

62 

63.. rubric:: Licenses 

64 

65PyPI reports a package's license, and GitHub a repository's, so ``:source-license:`` needs only the link - the badge 

66asks PyPI when ``:pypi:`` is stated, and GitHub otherwise. An SPDX expression before the link replaces what they 

67report. Nothing reports a documentation's license, so ``:documentation-license:`` states it 

68first. Both are parsed with :meth:`~pyTooling.Licensing.LicenseExpression.Parse`, so a license outside the SPDX list 

69is written as ``LicenseRef-<name>``. 

70 

71.. rubric:: HTML and LaTeX 

72 

73Both variants are emitted, each wrapped in an :rst:dir:`only` node: HTML embeds the SVG from ``img.shields.io``, 

74LaTeX the PNG from ``raster.shields.io``, because a PDF cannot embed an SVG. 

75 

76.. seealso:: 

77 

78 :ref:`DOC/Sphinx/Shields` 

79 |rarr| The directive's options and badges, with a rendered example. 

80 :mod:`pyTooling.Documentation.Sphinx` 

81 |rarr| The extension this belongs to, and what else it brings. 

82""" 

83from typing import Any, Iterable, Optional as Nullable 

84from urllib.parse import quote 

85 

86from docutils import nodes 

87from sphinx.addnodes import only 

88 

89from pyTooling.Common import getFullyQualifiedName 

90from pyTooling.Decorators import export, readonly 

91from pyTooling.Licensing import BaseLicense, LicenseExpression, LicenseExpressionError 

92from pyTooling.MetaClasses import ExtendedType 

93from pyTooling.Documentation.Sphinx.Directives import BaseDirective, SphinxExtensionError, strip 

94 

95 

96__all__ = ["SHIELDS_SERVICE_SVG", "SHIELDS_SERVICE_PNG", "BADGE_HEIGHT", "SHIELDS"] 

97 

98#: Host serving a badge as SVG, which is what an HTML build embeds. 

99SHIELDS_SERVICE_SVG = "https://img.shields.io" 

100 

101#: Host serving the same badge rasterized, which is what a LaTeX build needs - a PDF cannot embed the SVG. 

102SHIELDS_SERVICE_PNG = "https://raster.shields.io" 

103 

104#: Height every badge is scaled to, in pixels. 

105BADGE_HEIGHT = 22 

106 

107 

108@export 

109class Shield(metaclass=ExtendedType, slots=True): 

110 """ 

111 One badge: where its image comes from, what it links to, what a screen reader is told, and which of the 

112 directive's options it is made of. 

113 

114 The image is stated as the part of the URL **after** the host, because the host is the only difference between 

115 the SVG an HTML build embeds and the PNG a LaTeX build needs. Image and target are formatted from the settings the 

116 directive derives from its options, so a placeholder such as ``{GitHubOrganization}`` is filled per page. 

117 """ 

118 

119 _alternativeText: str #: What the image says when it can't be shown. 

120 _path: str #: Path and query on the badge host, with placeholders to format. 

121 _target: Nullable[str] #: What the badge links to; ``None`` for a badge that links nowhere. 

122 _options: tuple[str, ...] #: Names of the directive's options the badge is made of. 

123 

124 def __init__( 

125 self, 

126 alternativeText: str, 

127 path: str, 

128 target: Nullable[str] = None, 

129 options: Nullable[Iterable[str]] = None 

130 ) -> None: 

131 """ 

132 Describe a badge. 

133 

134 :param alternativeText: What the image says when it can't be shown. 

135 :param path: Path and query on the badge host, with placeholders to format. 

136 :param target: Optional, what the badge links to. 

137 :param options: Optional, names of the directive's options the badge is made of. 

138 :raises ValueError: If parameter 'alternativeText' or 'path' is None. 

139 :raises TypeError: If parameter 'alternativeText', 'path' or 'target' is not a string. 

140 """ 

141 if alternativeText is None: 

142 raise ValueError("Parameter 'alternativeText' is None.") 

143 elif not isinstance(alternativeText, str): 

144 ex = TypeError("Parameter 'alternativeText' is not of type 'str'.") 

145 ex.add_note(f"Got type '{getFullyQualifiedName(alternativeText)}'.") 

146 raise ex 

147 

148 if path is None: 

149 raise ValueError("Parameter 'path' is None.") 

150 elif not isinstance(path, str): 

151 ex = TypeError("Parameter 'path' is not of type 'str'.") 

152 ex.add_note(f"Got type '{getFullyQualifiedName(path)}'.") 

153 raise ex 

154 

155 if target is not None and not isinstance(target, str): 

156 ex = TypeError("Parameter 'target' is not of type 'str'.") 

157 ex.add_note(f"Got type '{getFullyQualifiedName(target)}'.") 

158 raise ex 

159 

160 self._alternativeText = alternativeText 

161 self._path = path 

162 self._target = target 

163 self._options = () if options is None else tuple(options) 

164 

165 @readonly 

166 def AlternativeText(self) -> str: 

167 """ 

168 Read-only property to access what the image says when it can't be shown. 

169 

170 :returns: The badge's alternative text. 

171 """ 

172 return self._alternativeText 

173 

174 @readonly 

175 def Options(self) -> tuple[str, ...]: 

176 """ 

177 Read-only property to access the names of the directive's options the badge is made of. 

178 

179 :returns: The option names, without colons. 

180 """ 

181 return self._options 

182 

183 def ImageURL(self, settings: dict[str, str], latex: bool) -> str: 

184 """ 

185 Format the badge's image URL. 

186 

187 :param settings: The settings derived from the directive's options, which the path's placeholders name. 

188 :param latex: Whether the URL is for a LaTeX build, which needs the rasterized badge. 

189 :returns: The complete URL of the badge image. 

190 """ 

191 host = SHIELDS_SERVICE_PNG if latex else SHIELDS_SERVICE_SVG 

192 

193 return f"{host}/{self._path.format_map(settings)}" 

194 

195 def TargetURL(self, settings: dict[str, str]) -> Nullable[str]: 

196 """ 

197 Format what the badge links to. 

198 

199 :param settings: The settings derived from the directive's options, which the target's placeholders name. 

200 :returns: The complete target URL, or ``None`` when the badge links nowhere. 

201 """ 

202 if self._target is None: 

203 return None 

204 

205 return self._target.format_map(settings) 

206 

207 

208#: Every badge the directive knows, keyed by the identifier its content names. 

209SHIELDS: dict[str, Shield] = { 

210 "github": Shield( 

211 "Sourcecode on GitHub", 

212 "badge/{GitHubOrganization}-{GitHubRepository}-63bf7f?longCache=true&style=flat-square&longCache=true" 

213 "&logo=GitHub", 

214 "https://GitHub.com/{GitHubOrganization}/{GitHubRepository}", 

215 ("github",) 

216 ), 

217 "src-license": Shield( 

218 "Code license", 

219 "{SourceLicenseImage}?longCache=true&style=flat-square&label=code", 

220 "{SourceLicenseURL}", 

221 ("source-license",) 

222 ), 

223 "doc-license": Shield( 

224 "Documentation License", 

225 "badge/doc-{DocumentationLicenseBadge}-green?longCache=true&style=flat-square{DocumentationLicenseLogo}", 

226 "{DocumentationLicenseURL}", 

227 ("documentation-license",) 

228 ), 

229 "ghp-doc": Shield( 

230 "Documentation - Read Now!", 

231 "website?longCache=true&style=flat-square&label={DocumentationLabel}{DocumentationLogo}" 

232 "&up_color=blueviolet&up_message=Read%20now%20%E2%9E%9A&url={DocumentationQuery}", 

233 "{DocumentationURL}", 

234 ("documentation",) 

235 ), 

236 "tag": Shield( 

237 "GitHub tag (latest SemVer incl. pre-release)", 

238 "github/v/tag/{GitHubOrganization}/{GitHubRepository}?longCache=true&style=flat-square&logo=GitHub" 

239 "&include_prereleases", 

240 "https://GitHub.com/{GitHubOrganization}/{GitHubRepository}/tags", 

241 ("github",) 

242 ), 

243 "date": Shield( 

244 "GitHub release date", 

245 "github/release-date/{GitHubOrganization}/{GitHubRepository}?longCache=true&style=flat-square&logo=GitHub", 

246 "https://GitHub.com/{GitHubOrganization}/{GitHubRepository}/releases", 

247 ("github",) 

248 ), 

249 "github-action": Shield( 

250 "GitHub Workflow - Build and Test Status", 

251 "github/actions/workflow/status/{GitHubOrganization}/{GitHubRepository}/{Workflow}?{WorkflowBranch}" 

252 "longCache=true&style=flat-square&label=Build%20and%20Test&logo=GitHub%20Actions&logoColor=FFFFFF", 

253 "https://GitHub.com/{GitHubOrganization}/{GitHubRepository}/actions/workflows/{Workflow}", 

254 ("github", "github-action") 

255 ), 

256 "codacy-quality": Shield( 

257 "Codacy - Quality", 

258 "codacy/grade/{Codacy}?longCache=true&style=flat-square&logo=codacy", 

259 "https://app.codacy.com/gh/{GitHubOrganization}/{GitHubRepository}/dashboard", 

260 ("github", "codacy") 

261 ), 

262 "codacy-coverage": Shield( 

263 "Codacy - Line Coverage", 

264 "codacy/coverage/{Codacy}?longCache=true&style=flat-square&logo=codacy", 

265 "https://app.codacy.com/gh/{GitHubOrganization}/{GitHubRepository}/dashboard", 

266 ("github", "codacy") 

267 ), 

268 "codecov-coverage": Shield( 

269 "Codecov - Branch Coverage", 

270 "codecov/c/github/{GitHubOrganization}/{GitHubRepository}?longCache=true&style=flat-square&logo=Codecov", 

271 "https://codecov.io/gh/{GitHubOrganization}/{GitHubRepository}", 

272 ("github",) 

273 ), 

274 "lib-status": Shield( 

275 "Libraries.io status for latest release", 

276 "librariesio/release/pypi/{PyPI}?longCache=true&style=flat-square&logo=Libraries.io&logoColor=fff", 

277 "https://libraries.io/pypi/{PyPI}", 

278 ("pypi",) 

279 ), 

280 "lib-rank": Shield( 

281 "Libraries.io SourceRank", 

282 "librariesio/sourcerank/pypi/{PyPI}?longCache=true&style=flat-square&logo=Libraries.io&logoColor=fff", 

283 "https://libraries.io/pypi/{PyPI}/sourcerank", 

284 ("pypi",) 

285 ), 

286 "lib-dep": Shield( 

287 "Dependent repos (via libraries.io)", 

288 "librariesio/dependent-repos/pypi/{PyPI}?longCache=true&style=flat-square&logo=Libraries.io&logoColor=fff", 

289 "https://GitHub.com/{GitHubOrganization}/{GitHubRepository}/network/dependents", 

290 ("github", "pypi") 

291 ), 

292 "pypi-tag": Shield( 

293 "PyPI - Tag", 

294 "pypi/v/{PyPI}?longCache=true&style=flat-square&logo=PyPI&logoColor=FBE072", 

295 "https://pypi.org/project/{PyPI}/", 

296 ("pypi",) 

297 ), 

298 "pypi-status": Shield( 

299 "PyPI - Status", 

300 "pypi/status/{PyPI}?longCache=true&style=flat-square&logo=PyPI&logoColor=FBE072", 

301 options=("pypi",) 

302 ), 

303 "pypi-python": Shield( 

304 "PyPI - Python Version", 

305 "pypi/pyversions/{PyPI}?longCache=true&style=flat-square&logo=PyPI&logoColor=FBE072", 

306 options=("pypi",) 

307 ), 

308 "gitter": Shield( 

309 "Chat on gitter", 

310 "badge/chat-on%20gitter-4db797?longCache=true&style=flat-square&logo=gitter&logoColor=e8ecef", 

311 "https://gitter.im/{Gitter}", 

312 ("gitter",) 

313 ), 

314} 

315 

316 

317@export 

318class Shields(BaseDirective): 

319 """ 

320 The ``shields`` directive: a project's badges, in rows. 

321 

322 The options state the project's coordinates, the content names the badges - one line per row, identifiers 

323 separated by commas - and :data:`SHIELDS` is what the identifiers name. ``:class:`` puts additional CSS classes on 

324 the rows. 

325 """ 

326 

327 directiveName: str = "shields" #: Name the directive is invoked by. 

328 

329 has_content = True #: A boolean; ``True`` if content is allowed. 

330 required_arguments = 0 #: Number of required directive arguments. 

331 optional_arguments = 0 #: Number of optional arguments after the required ones. 

332 final_argument_whitespace = False #: A boolean; ``True`` if the last argument may contain spaces. 

333 # docutils declares 'option_spec' on 'Directive' and 'BaseDirective' assigns it, so mypy calls every 

334 # spelling of this override a conflict with one of them 

335 #: Mapping of option names to validator functions. 

336 option_spec: dict[str, Any] = { # type: ignore[misc] 

337 "github": strip, 

338 "pypi": strip, 

339 "codacy": strip, 

340 "gitter": strip, 

341 "source-license": strip, 

342 "documentation-license": strip, 

343 "github-action": strip, 

344 "documentation": strip, 

345 "class": strip, 

346 } 

347 

348 def run(self) -> list[nodes.Node]: 

349 """ 

350 Render the badges named in the content, once for HTML and once for LaTeX. 

351 

352 :returns: Two ``only`` nodes, or the message of whatever the options or the content got wrong. 

353 """ 

354 try: 

355 rows = self._ParseRows(self.content) 

356 self._CheckOptions(rows, self.options) 

357 settings = self._Settings(self.options) 

358 except SphinxExtensionError as ex: 

359 return [self.state.document.reporter.error(f"{self.directiveName}: {ex}", line=self.lineno)] 

360 

361 classes = ["shields"] + self.options.get("class", "").split() 

362 

363 return [self._Only("html", rows, settings, classes), self._Only("latex", rows, settings, classes)] 

364 

365 @staticmethod 

366 def _ParseRows(content: Iterable[str]) -> list[list[str]]: 

367 """ 

368 Read the content: one row per line, identifiers separated by commas. 

369 

370 :param content: The directive's content, line by line. 

371 :returns: The identifiers, grouped into the rows they were written in. 

372 :raises SphinxExtensionError: If the content is empty, or names a badge :data:`SHIELDS` doesn't know. 

373 """ 

374 rows = [] 

375 for line in content: 

376 identifiers = [identifier.strip() for identifier in line.split(",") if identifier.strip() != ""] 

377 if len(identifiers) > 0: 

378 rows.append(identifiers) 

379 

380 if len(rows) == 0: 

381 raise SphinxExtensionError("The directive's content names no badge.") 

382 

383 for row in rows: 

384 for identifier in row: 

385 if identifier not in SHIELDS: 

386 known = ", ".join(sorted(SHIELDS)) 

387 raise SphinxExtensionError(f"'{identifier}' is not a known badge. Known are: {known}.") 

388 

389 return rows 

390 

391 @staticmethod 

392 def _CheckOptions(rows: list[list[str]], options: dict[str, str]) -> None: 

393 """ 

394 Check that every badge named has the options it is made of. 

395 

396 :param rows: The identifiers, grouped into rows. 

397 :param options: The directive's options. 

398 :raises SphinxExtensionError: If a badge needs an option the directive doesn't state. 

399 """ 

400 for row in rows: 

401 for identifier in row: 

402 for option in SHIELDS[identifier].Options: 

403 if option not in options: 

404 raise SphinxExtensionError(f"Badge '{identifier}' needs option ':{option}:'.") 

405 

406 @classmethod 

407 def _Settings(cls, options: dict[str, str]) -> dict[str, str]: 

408 """ 

409 Derive the values the badge URLs are formatted from. 

410 

411 An option that isn't stated derives nothing; :meth:`_CheckOptions` ensures that no badge needing it is drawn. 

412 

413 :param options: The directive's options. 

414 :returns: The values, keyed by the placeholders :data:`SHIELDS` names. 

415 :raises SphinxExtensionError: If an option's value is malformed, or refers to ``:github:`` without it. 

416 """ 

417 settings: dict[str, str] = {} 

418 

419 if (gitHub := options.get("github", None)) is not None: 

420 if gitHub.count("/") != 1: 

421 raise SphinxExtensionError(f"Option ':github:' is '{gitHub}', not '<organization>/<repository>'.") 

422 

423 organization, repository = gitHub.split("/") 

424 settings["GitHubOrganization"] = organization 

425 settings["GitHubRepository"] = repository 

426 

427 if (pypi := options.get("pypi", None)) is not None: 

428 settings["PyPI"] = pypi 

429 

430 if (codacy := options.get("codacy", None)) is not None: 

431 settings["Codacy"] = codacy 

432 

433 if (gitter := options.get("gitter", None)) is not None: 

434 settings["Gitter"] = gitter 

435 

436 if (sourceLicense := options.get("source-license", None)) is not None: 

437 expression, link = cls._SplitLicense("source-license", sourceLicense) 

438 settings["SourceLicenseURL"] = cls._ResolveLink("source-license", link, settings) 

439 if expression is not None: 

440 settings["SourceLicenseImage"] = f"badge/code-{cls._EscapeBadgeText(str(expression))}-blue" 

441 elif "PyPI" in settings: 

442 settings["SourceLicenseImage"] = f"pypi/l/{settings['PyPI']}" 

443 else: 

444 settings["SourceLicenseImage"] = f"github/license/{cls._GitHubSlug('source-license', settings)}" 

445 

446 if (documentationLicense := options.get("documentation-license", None)) is not None: 

447 expression, link = cls._SplitLicense("documentation-license", documentationLicense) 

448 if expression is None: 

449 raise SphinxExtensionError( 

450 "Option ':documentation-license:' states no license. Nothing reports a documentation's license, so " 

451 "it is written before the link: '<SPDX expression> <link>'." 

452 ) 

453 

454 settings["DocumentationLicenseURL"] = cls._ResolveLink("documentation-license", link, settings) 

455 settings["DocumentationLicenseBadge"] = cls._EscapeBadgeText(str(expression)) 

456 identifiers = [term.Identifier for term in expression.IterateExpression() if isinstance(term, BaseLicense)] 

457 if all(identifier.startswith("CC") for identifier in identifiers): 

458 settings["DocumentationLicenseLogo"] = "&logo=CreativeCommons&logoColor=fff" 

459 else: 

460 settings["DocumentationLicenseLogo"] = "" 

461 

462 if (gitHubAction := options.get("github-action", None)) is not None: 

463 workflow, _, branch = gitHubAction.partition("@") 

464 settings["Workflow"] = workflow 

465 settings["WorkflowBranch"] = "" if branch == "" else f"branch={quote(branch, safe='')}&" 

466 

467 if (documentation := options.get("documentation", None)) is not None: 

468 if documentation == "github-pages": 

469 organization, repository = cls._GitHubSlug("documentation", settings).split("/") 

470 url = f"https://{organization}.github.io/{repository}/" 

471 logo = "&logo=GitHub&logoColor=fff" 

472 elif documentation.startswith(("http://", "https://")): 

473 url = documentation 

474 logo = "&logo=ReadTheDocs&logoColor=fff" if ".readthedocs." in documentation else "" 

475 else: 

476 raise SphinxExtensionError( 

477 f"Option ':documentation:' is '{documentation}', neither 'github-pages' nor an 'http(s)://' URL." 

478 ) 

479 

480 settings["DocumentationURL"] = url 

481 settings["DocumentationLabel"] = quote(url.split("://", 1)[1].rstrip("/"), safe="") 

482 settings["DocumentationQuery"] = quote(url, safe="") 

483 settings["DocumentationLogo"] = logo 

484 

485 return settings 

486 

487 @staticmethod 

488 def _SplitLicense(option: str, value: str) -> tuple[Nullable[LicenseExpression], str]: 

489 """ 

490 Split a license option into the license and the link, which is its last word. 

491 

492 :param option: The option's name, for the message. 

493 :param value: The option's value, ``[<SPDX expression> ]<link>``. 

494 :returns: The parsed license expression, or ``None`` if none is stated, and the link. 

495 :raises SphinxExtensionError: If the license isn't an SPDX license expression. 

496 """ 

497 words = value.rsplit(maxsplit=1) 

498 if len(words) == 1: 

499 return None, words[0] 

500 

501 text, link = words 

502 try: 

503 expression = LicenseExpression.Parse(text) 

504 except LicenseExpressionError as cause: 

505 ex = SphinxExtensionError(f"Option ':{option}:' states '{text}', which isn't an SPDX license expression.") 

506 ex.add_note(str(cause)) 

507 raise ex from cause 

508 

509 return expression, link 

510 

511 @classmethod 

512 def _ResolveLink(cls, option: str, link: str, settings: dict[str, str]) -> str: 

513 """ 

514 Resolve a link written as ``github:<path>`` or as a URL. 

515 

516 :param option: The option's name, for the message. 

517 :param link: The link as written. 

518 :param settings: The settings derived so far, which hold the repository ``:github:`` names. 

519 :returns: The link's URL. 

520 :raises SphinxExtensionError: If the link is neither form, or is ``github:`` without ``:github:``. 

521 """ 

522 if link.startswith("github:"): 

523 return f"https://GitHub.com/{cls._GitHubSlug(option, settings)}/blob/HEAD/{link.removeprefix('github:')}" 

524 elif link.startswith(("http://", "https://")): 

525 return link 

526 

527 raise SphinxExtensionError(f"Option ':{option}:' links to '{link}', neither 'github:<path>' nor a URL.") 

528 

529 @staticmethod 

530 def _GitHubSlug(option: str, settings: dict[str, str]) -> str: 

531 """ 

532 Return the repository ``:github:`` names, for an option referring to it. 

533 

534 :param option: The option's name, for the message. 

535 :param settings: The settings derived so far. 

536 :returns: ``<organization>/<repository>``. 

537 :raises SphinxExtensionError: If ``:github:`` isn't stated. 

538 """ 

539 if "GitHubOrganization" not in settings: 

540 raise SphinxExtensionError(f"Option ':{option}:' refers to the repository, which needs option ':github:'.") 

541 

542 return f"{settings['GitHubOrganization']}/{settings['GitHubRepository']}" 

543 

544 @staticmethod 

545 def _EscapeBadgeText(text: str) -> str: 

546 """ 

547 Escape text for a static badge's path, where ``-`` and ``_`` separate the badge's fields. 

548 

549 :param text: The text to escape. 

550 :returns: The text with ``-`` and ``_`` doubled and everything else a URL path can't hold percent-encoded. 

551 """ 

552 return quote(text.replace("-", "--").replace("_", "__"), safe="") 

553 

554 def _Only(self, expression: str, rows: list[list[str]], settings: dict[str, str], classes: list[str]) -> only: 

555 """ 

556 Build one builder-specific variant of the badges. 

557 

558 :param expression: The ``only`` expression selecting the builder, ``html`` or ``latex``. 

559 :param rows: The identifiers, grouped into rows. 

560 :param settings: The settings derived from the directive's options, filling the URLs. 

561 :param classes: CSS classes to put on the block. 

562 :returns: An ``only`` node holding a line per row. 

563 """ 

564 latex = expression == "latex" 

565 node = only("", expr=expression) 

566 node += (block := nodes.line_block(classes=classes)) 

567 

568 for row in rows: 

569 block += (line := nodes.line()) 

570 for identifier in row: 

571 line += self._Badge(SHIELDS[identifier], settings, latex) 

572 

573 return node 

574 

575 @staticmethod 

576 def _Badge(shield: Shield, settings: dict[str, str], latex: bool) -> nodes.Node: 

577 """ 

578 Build one badge: an image, wrapped in a reference when it links somewhere. 

579 

580 :param shield: The badge to build. 

581 :param settings: The settings derived from the directive's options, filling the URLs. 

582 :param latex: Whether this is the LaTeX variant. 

583 :returns: The image, or the reference holding it. 

584 """ 

585 image = nodes.image( 

586 "", 

587 uri=shield.ImageURL(settings, latex), 

588 alt=shield.AlternativeText, 

589 height=str(BADGE_HEIGHT) 

590 ) 

591 

592 if (target := shield.TargetURL(settings)) is None: 

593 return image 

594 

595 reference = nodes.reference("", refuri=target) 

596 reference += image 

597 

598 return reference