Shields.py
Summary
- Lines:
169 of 191 covered (88.5%)
- Branches:
70 of 76 taken (92.1%), 0 lines partially
- Coverage:
89.5%
- Directory:
- Missed lines:
357-366, 569-578, 590-603
Source
pyTooling/Sphinx/Shields.pycoveredpartialuncoveredexcludednot executable
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:`DIR/Shields`
79 |rarr| The directive's options and badges, with a rendered example.
80 :mod:`pyTooling.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.Sphinx import BaseDirective, SphinxExtensionError, strip
94
95
96__all__ = ["SHIELDS_SERVICE_SVG", "SHIELDS_SERVICE_PNG", "BADGE_HEIGHT", "SHIELDS"]
97
98SHIELDS_SERVICE_SVG = "https://img.shields.io"
99"""Host serving a badge as SVG, which is what an HTML build embeds."""
100
101SHIELDS_SERVICE_PNG = "https://raster.shields.io"
102"""Host serving the same badge rasterized, which is what a LaTeX build needs - a PDF cannot embed the SVG."""
103
104BADGE_HEIGHT = 22
105"""Height every badge is scaled to, in pixels."""
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' is None.
139 :raises TypeError: If parameter 'alternativeText' is not a string.
140 :raises ValueError: If parameter 'path' is None.
141 :raises TypeError: If parameter 'path' is not a string.
142 :raises TypeError: If parameter 'target' is not a string.
143 """
144 if alternativeText is None:
145 raise ValueError("Parameter 'alternativeText' is None.")
146 elif not isinstance(alternativeText, str):
147 ex = TypeError("Parameter 'alternativeText' is not of type 'str'.")
148 ex.add_note(f"Got type '{getFullyQualifiedName(alternativeText)}'.")
149 raise ex
150
151 if path is None:
152 raise ValueError("Parameter 'path' is None.")
153 elif not isinstance(path, str):
154 ex = TypeError("Parameter 'path' is not of type 'str'.")
155 ex.add_note(f"Got type '{getFullyQualifiedName(path)}'.")
156 raise ex
157
158 if target is not None and not isinstance(target, str):
159 ex = TypeError("Parameter 'target' is not of type 'str'.")
160 ex.add_note(f"Got type '{getFullyQualifiedName(target)}'.")
161 raise ex
162
163 self._alternativeText = alternativeText
164 self._path = path
165 self._target = target
166 self._options = () if options is None else tuple(options)
167
168 @readonly
169 def AlternativeText(self) -> str:
170 """
171 Read-only property to access what the image says when it can't be shown.
172
173 :returns: The badge's alternative text.
174 """
175 return self._alternativeText
176
177 @readonly
178 def Options(self) -> tuple[str, ...]:
179 """
180 Read-only property to access the names of the directive's options the badge is made of.
181
182 :returns: The option names, without colons.
183 """
184 return self._options
185
186 def ImageURL(self, settings: dict[str, str], latex: bool) -> str:
187 """
188 Format the badge's image URL.
189
190 :param settings: The settings derived from the directive's options, which the path's placeholders name.
191 :param latex: Whether the URL is for a LaTeX build, which needs the rasterized badge.
192 :returns: The complete URL of the badge image.
193 """
194 host = SHIELDS_SERVICE_PNG if latex else SHIELDS_SERVICE_SVG
195
196 return f"{host}/{self._path.format_map(settings)}"
197
198 def TargetURL(self, settings: dict[str, str]) -> Nullable[str]:
199 """
200 Format what the badge links to.
201
202 :param settings: The settings derived from the directive's options, which the target's placeholders name.
203 :returns: The complete target URL, or ``None`` when the badge links nowhere.
204 """
205 if self._target is None:
206 return None
207
208 return self._target.format_map(settings)
209
210
211SHIELDS: dict[str, Shield] = {
212 "github": Shield(
213 "Sourcecode on GitHub",
214 "badge/{GitHubOrganizationBadge}-{GitHubRepositoryBadge}-63bf7f?longCache=true&style=flat-square&longCache=true"
215 "&logo=GitHub",
216 "https://GitHub.com/{GitHubOrganization}/{GitHubRepository}",
217 ("github",)
218 ),
219 "src-license": Shield(
220 "Code license",
221 "{SourceLicenseImage}?longCache=true&style=flat-square&label=code",
222 "{SourceLicenseURL}",
223 ("source-license",)
224 ),
225 "doc-license": Shield(
226 "Documentation License",
227 "badge/doc-{DocumentationLicenseBadge}-green?longCache=true&style=flat-square{DocumentationLicenseLogo}",
228 "{DocumentationLicenseURL}",
229 ("documentation-license",)
230 ),
231 "ghp-doc": Shield(
232 "Documentation - Read Now!",
233 "website?longCache=true&style=flat-square&label={DocumentationLabel}{DocumentationLogo}"
234 "&up_color=blueviolet&up_message=Read%20now%20%E2%9E%9A&url={DocumentationQuery}",
235 "{DocumentationURL}",
236 ("documentation",)
237 ),
238 "tag": Shield(
239 "GitHub tag (latest SemVer incl. pre-release)",
240 "github/v/tag/{GitHubOrganization}/{GitHubRepository}?longCache=true&style=flat-square&logo=GitHub"
241 "&include_prereleases",
242 "https://GitHub.com/{GitHubOrganization}/{GitHubRepository}/tags",
243 ("github",)
244 ),
245 "date": Shield(
246 "GitHub release date",
247 "github/release-date/{GitHubOrganization}/{GitHubRepository}?longCache=true&style=flat-square&logo=GitHub",
248 "https://GitHub.com/{GitHubOrganization}/{GitHubRepository}/releases",
249 ("github",)
250 ),
251 "github-action": Shield(
252 "GitHub Workflow - Build and Test Status",
253 "github/actions/workflow/status/{GitHubOrganization}/{GitHubRepository}/{Workflow}?{WorkflowBranch}"
254 "longCache=true&style=flat-square&label=Build%20and%20Test&logo=GitHub%20Actions&logoColor=FFFFFF",
255 "https://GitHub.com/{GitHubOrganization}/{GitHubRepository}/actions/workflows/{Workflow}",
256 ("github", "github-action")
257 ),
258 "codacy-quality": Shield(
259 "Codacy - Quality",
260 "codacy/grade/{Codacy}?longCache=true&style=flat-square&logo=codacy",
261 "https://app.codacy.com/gh/{GitHubOrganization}/{GitHubRepository}/dashboard",
262 ("github", "codacy")
263 ),
264 "codacy-coverage": Shield(
265 "Codacy - Line Coverage",
266 "codacy/coverage/{Codacy}?longCache=true&style=flat-square&logo=codacy",
267 "https://app.codacy.com/gh/{GitHubOrganization}/{GitHubRepository}/dashboard",
268 ("github", "codacy")
269 ),
270 "codecov-coverage": Shield(
271 "Codecov - Branch Coverage",
272 "codecov/c/github/{GitHubOrganization}/{GitHubRepository}?longCache=true&style=flat-square&logo=Codecov",
273 "https://codecov.io/gh/{GitHubOrganization}/{GitHubRepository}",
274 ("github",)
275 ),
276 "lib-status": Shield(
277 "Libraries.io status for latest release",
278 "librariesio/release/pypi/{PyPI}?longCache=true&style=flat-square&logo=Libraries.io&logoColor=fff",
279 "https://libraries.io/pypi/{PyPI}",
280 ("pypi",)
281 ),
282 "lib-rank": Shield(
283 "Libraries.io SourceRank",
284 "librariesio/sourcerank/pypi/{PyPI}?longCache=true&style=flat-square&logo=Libraries.io&logoColor=fff",
285 "https://libraries.io/pypi/{PyPI}/sourcerank",
286 ("pypi",)
287 ),
288 "lib-dep": Shield(
289 "Dependent repos (via libraries.io)",
290 "librariesio/dependent-repos/pypi/{PyPI}?longCache=true&style=flat-square&logo=Libraries.io&logoColor=fff",
291 "https://GitHub.com/{GitHubOrganization}/{GitHubRepository}/network/dependents",
292 ("github", "pypi")
293 ),
294 "pypi-tag": Shield(
295 "PyPI - Tag",
296 "pypi/v/{PyPI}?longCache=true&style=flat-square&logo=PyPI&logoColor=FBE072",
297 "https://pypi.org/project/{PyPI}/",
298 ("pypi",)
299 ),
300 "pypi-status": Shield(
301 "PyPI - Status",
302 "pypi/status/{PyPI}?longCache=true&style=flat-square&logo=PyPI&logoColor=FBE072",
303 options=("pypi",)
304 ),
305 "pypi-python": Shield(
306 "PyPI - Python Version",
307 "pypi/pyversions/{PyPI}?longCache=true&style=flat-square&logo=PyPI&logoColor=FBE072",
308 options=("pypi",)
309 ),
310 "gitter": Shield(
311 "Chat on gitter",
312 "badge/chat-on%20gitter-4db797?longCache=true&style=flat-square&logo=gitter&logoColor=e8ecef",
313 "https://gitter.im/{Gitter}",
314 ("gitter",)
315 ),
316}
317"""Every badge the directive knows, keyed by the identifier its content names."""
318
319
320@export
321class Shields(BaseDirective):
322 """
323 The ``shields`` directive: a project's badges, in rows.
324
325 The options state the project's coordinates, the content names the badges - one line per row, identifiers
326 separated by commas - and :data:`SHIELDS` is what the identifiers name. ``:class:`` puts additional CSS classes on
327 the rows.
328 """
329
330 directiveName: str = "shields" #: Name the directive is invoked by.
331
332 has_content = True #: A boolean; ``True`` if content is allowed.
333 required_arguments = 0 #: Number of required directive arguments.
334 optional_arguments = 0 #: Number of optional arguments after the required ones.
335 final_argument_whitespace = False #: A boolean; ``True`` if the last argument may contain spaces.
336 # docutils declares 'option_spec' on 'Directive' and 'BaseDirective' assigns it, so mypy calls every
337 # spelling of this override a conflict with one of them
338 #: Mapping of option names to validator functions.
339 option_spec: dict[str, Any] = { # type: ignore[misc]
340 "github": strip,
341 "pypi": strip,
342 "codacy": strip,
343 "gitter": strip,
344 "source-license": strip,
345 "documentation-license": strip,
346 "github-action": strip,
347 "documentation": strip,
348 "class": strip,
349 }
350
351 def run(self) -> list[nodes.Node]:
352 """
353 Render the badges named in the content, once for HTML and once for LaTeX.
354
355 :returns: Two ``only`` nodes, or the message of whatever the options or the content got wrong.
356 """
357 try:
358 rows = self._ParseRows(self.content)
359 self._CheckOptions(rows, self.options)
360 settings = self._Settings(self.options)
361 except SphinxExtensionError as ex:
362 return [self.state.document.reporter.error(f"{self.directiveName}: {ex}", line=self.lineno)]
363
364 classes = ["shields"] + self.options.get("class", "").split()
365
366 return [self._Only("html", rows, settings, classes), self._Only("latex", rows, settings, classes)]
367
368 @staticmethod
369 def _ParseRows(content: Iterable[str]) -> list[list[str]]:
370 """
371 Read the content: one row per line, identifiers separated by commas.
372
373 :param content: The directive's content, line by line.
374 :returns: The identifiers, grouped into the rows they were written in.
375 :raises SphinxExtensionError: If the content is empty, or names a badge :data:`SHIELDS` doesn't know.
376 """
377 rows = []
378 for line in content:
379 identifiers = [identifier.strip() for identifier in line.split(",") if identifier.strip() != ""]
380 if len(identifiers) > 0:
381 rows.append(identifiers)
382
383 if len(rows) == 0:
384 raise SphinxExtensionError("The directive's content names no badge.")
385
386 for row in rows:
387 for identifier in row:
388 if identifier not in SHIELDS:
389 known = ", ".join(sorted(SHIELDS))
390 raise SphinxExtensionError(f"'{identifier}' is not a known badge. Known are: {known}.")
391
392 return rows
393
394 @staticmethod
395 def _CheckOptions(rows: list[list[str]], options: dict[str, str]) -> None:
396 """
397 Check that every badge named has the options it is made of.
398
399 :param rows: The identifiers, grouped into rows.
400 :param options: The directive's options.
401 :raises SphinxExtensionError: If a badge needs an option the directive doesn't state.
402 """
403 for row in rows:
404 for identifier in row:
405 for option in SHIELDS[identifier].Options:
406 if option not in options:
407 raise SphinxExtensionError(f"Badge '{identifier}' needs option ':{option}:'.")
408
409 @classmethod
410 def _Settings(cls, options: dict[str, str]) -> dict[str, str]:
411 """
412 Derive the values the badge URLs are formatted from.
413
414 An option that isn't stated derives nothing; :meth:`_CheckOptions` ensures that no badge needing it is drawn.
415
416 :param options: The directive's options.
417 :returns: The values, keyed by the placeholders :data:`SHIELDS` names.
418 :raises SphinxExtensionError: If an option's value is malformed, or refers to ``:github:`` without it.
419 """
420 settings: dict[str, str] = {}
421
422 if (gitHub := options.get("github", None)) is not None:
423 if gitHub.count("/") != 1:
424 raise SphinxExtensionError(f"Option ':github:' is '{gitHub}', not '<organization>/<repository>'.")
425
426 organization, repository = gitHub.split("/")
427 settings["GitHubOrganization"] = organization
428 settings["GitHubRepository"] = repository
429 settings["GitHubOrganizationBadge"] = cls._EscapeBadgeText(organization)
430 settings["GitHubRepositoryBadge"] = cls._EscapeBadgeText(repository)
431
432 if (pypi := options.get("pypi", None)) is not None:
433 settings["PyPI"] = pypi
434
435 if (codacy := options.get("codacy", None)) is not None:
436 settings["Codacy"] = codacy
437
438 if (gitter := options.get("gitter", None)) is not None:
439 settings["Gitter"] = gitter
440
441 if (sourceLicense := options.get("source-license", None)) is not None:
442 expression, link = cls._SplitLicense("source-license", sourceLicense)
443 settings["SourceLicenseURL"] = cls._ResolveLink("source-license", link, settings)
444 if expression is not None:
445 settings["SourceLicenseImage"] = f"badge/code-{cls._EscapeBadgeText(str(expression))}-blue"
446 elif "PyPI" in settings:
447 settings["SourceLicenseImage"] = f"pypi/l/{settings['PyPI']}"
448 else:
449 settings["SourceLicenseImage"] = f"github/license/{cls._GitHubSlug('source-license', settings)}"
450
451 if (documentationLicense := options.get("documentation-license", None)) is not None:
452 expression, link = cls._SplitLicense("documentation-license", documentationLicense)
453 if expression is None:
454 raise SphinxExtensionError(
455 "Option ':documentation-license:' states no license. Nothing reports a documentation's license, so "
456 "it is written before the link: '<SPDX expression> <link>'."
457 )
458
459 settings["DocumentationLicenseURL"] = cls._ResolveLink("documentation-license", link, settings)
460 settings["DocumentationLicenseBadge"] = cls._EscapeBadgeText(str(expression))
461 identifiers = [term.Identifier for term in expression.IterateExpression() if isinstance(term, BaseLicense)]
462 if all(identifier.startswith("CC") for identifier in identifiers):
463 settings["DocumentationLicenseLogo"] = "&logo=CreativeCommons&logoColor=fff"
464 else:
465 settings["DocumentationLicenseLogo"] = ""
466
467 if (gitHubAction := options.get("github-action", None)) is not None:
468 workflow, _, branch = gitHubAction.partition("@")
469 settings["Workflow"] = workflow
470 settings["WorkflowBranch"] = "" if branch == "" else f"branch={quote(branch, safe='')}&"
471
472 if (documentation := options.get("documentation", None)) is not None:
473 if documentation == "github-pages":
474 organization, repository = cls._GitHubSlug("documentation", settings).split("/")
475 url = f"https://{organization}.github.io/{repository}/"
476 logo = "&logo=GitHub&logoColor=fff"
477 elif documentation.startswith(("http://", "https://")):
478 url = documentation
479 logo = "&logo=ReadTheDocs&logoColor=fff" if ".readthedocs." in documentation else ""
480 else:
481 raise SphinxExtensionError(
482 f"Option ':documentation:' is '{documentation}', neither 'github-pages' nor an 'http(s)://' URL."
483 )
484
485 settings["DocumentationURL"] = url
486 settings["DocumentationLabel"] = quote(url.split("://", 1)[1].rstrip("/"), safe="")
487 settings["DocumentationQuery"] = quote(url, safe="")
488 settings["DocumentationLogo"] = logo
489
490 return settings
491
492 @staticmethod
493 def _SplitLicense(option: str, value: str) -> tuple[Nullable[LicenseExpression], str]:
494 """
495 Split a license option into the license and the link, which is its last word.
496
497 :param option: The option's name, for the message.
498 :param value: The option's value, ``[<SPDX expression> ]<link>``.
499 :returns: The parsed license expression, or ``None`` if none is stated, and the link.
500 :raises SphinxExtensionError: If the license isn't an SPDX license expression.
501 """
502 words = value.rsplit(maxsplit=1)
503 if len(words) == 1:
504 return None, words[0]
505
506 text, link = words
507 try:
508 expression = LicenseExpression.Parse(text)
509 except LicenseExpressionError as cause:
510 ex = SphinxExtensionError(f"Option ':{option}:' states '{text}', which isn't an SPDX license expression.")
511 ex.add_note(str(cause))
512 raise ex from cause
513
514 return expression, link
515
516 @classmethod
517 def _ResolveLink(cls, option: str, link: str, settings: dict[str, str]) -> str:
518 """
519 Resolve a link written as ``github:<path>`` or as a URL.
520
521 :param option: The option's name, for the message.
522 :param link: The link as written.
523 :param settings: The settings derived so far, which hold the repository ``:github:`` names.
524 :returns: The link's URL.
525 :raises SphinxExtensionError: If the link is neither form, or is ``github:`` without ``:github:``.
526 """
527 if link.startswith("github:"):
528 return f"https://GitHub.com/{cls._GitHubSlug(option, settings)}/blob/HEAD/{link.removeprefix('github:')}"
529 elif link.startswith(("http://", "https://")):
530 return link
531
532 raise SphinxExtensionError(f"Option ':{option}:' links to '{link}', neither 'github:<path>' nor a URL.")
533
534 @staticmethod
535 def _GitHubSlug(option: str, settings: dict[str, str]) -> str:
536 """
537 Return the repository ``:github:`` names, for an option referring to it.
538
539 :param option: The option's name, for the message.
540 :param settings: The settings derived so far.
541 :returns: ``<organization>/<repository>``.
542 :raises SphinxExtensionError: If ``:github:`` isn't stated.
543 """
544 if "GitHubOrganization" not in settings:
545 raise SphinxExtensionError(f"Option ':{option}:' refers to the repository, which needs option ':github:'.")
546
547 return f"{settings['GitHubOrganization']}/{settings['GitHubRepository']}"
548
549 @staticmethod
550 def _EscapeBadgeText(text: str) -> str:
551 """
552 Escape text for a static badge's path, where ``-`` and ``_`` separate the badge's fields.
553
554 :param text: The text to escape.
555 :returns: The text with ``-`` and ``_`` doubled and everything else a URL path can't hold percent-encoded.
556 """
557 return quote(text.replace("-", "--").replace("_", "__"), safe="")
558
559 def _Only(self, expression: str, rows: list[list[str]], settings: dict[str, str], classes: list[str]) -> only:
560 """
561 Build one builder-specific variant of the badges.
562
563 :param expression: The ``only`` expression selecting the builder, ``html`` or ``latex``.
564 :param rows: The identifiers, grouped into rows.
565 :param settings: The settings derived from the directive's options, filling the URLs.
566 :param classes: CSS classes to put on the block.
567 :returns: An ``only`` node holding a line per row.
568 """
569 latex = expression == "latex"
570 node = only("", expr=expression)
571 node += (block := nodes.line_block(classes=classes))
572
573 for row in rows:
574 block += (line := nodes.line())
575 for identifier in row:
576 line += self._Badge(SHIELDS[identifier], settings, latex)
577
578 return node
579
580 @staticmethod
581 def _Badge(shield: Shield, settings: dict[str, str], latex: bool) -> nodes.Node:
582 """
583 Build one badge: an image, wrapped in a reference when it links somewhere.
584
585 :param shield: The badge to build.
586 :param settings: The settings derived from the directive's options, filling the URLs.
587 :param latex: Whether this is the LaTeX variant.
588 :returns: The image, or the reference holding it.
589 """
590 image = nodes.image(
591 "",
592 uri=shield.ImageURL(settings, latex),
593 alt=shield.AlternativeText,
594 height=str(BADGE_HEIGHT)
595 )
596
597 if (target := shield.TargetURL(settings)) is None:
598 return image
599
600 reference = nodes.reference("", refuri=target)
601 reference += image
602
603 return reference