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
« 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.
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:
37.. code-block:: ReST
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
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
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.
56.. rubric:: Links
58``:source-license:`` and ``:documentation-license:`` link to where the license is written:
60* ``github:<path>`` is a file in the repository ``:github:`` names, on its default branch.
61* ``http://…`` or ``https://…`` is used as written.
63.. rubric:: Licenses
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>``.
71.. rubric:: HTML and LaTeX
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.
76.. seealso::
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
86from docutils import nodes
87from sphinx.addnodes import only
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
96__all__ = ["SHIELDS_SERVICE_SVG", "SHIELDS_SERVICE_PNG", "BADGE_HEIGHT", "SHIELDS"]
98#: Host serving a badge as SVG, which is what an HTML build embeds.
99SHIELDS_SERVICE_SVG = "https://img.shields.io"
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"
104#: Height every badge is scaled to, in pixels.
105BADGE_HEIGHT = 22
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.
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 """
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.
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.
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
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
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
160 self._alternativeText = alternativeText
161 self._path = path
162 self._target = target
163 self._options = () if options is None else tuple(options)
165 @readonly
166 def AlternativeText(self) -> str:
167 """
168 Read-only property to access what the image says when it can't be shown.
170 :returns: The badge's alternative text.
171 """
172 return self._alternativeText
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.
179 :returns: The option names, without colons.
180 """
181 return self._options
183 def ImageURL(self, settings: dict[str, str], latex: bool) -> str:
184 """
185 Format the badge's image URL.
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
193 return f"{host}/{self._path.format_map(settings)}"
195 def TargetURL(self, settings: dict[str, str]) -> Nullable[str]:
196 """
197 Format what the badge links to.
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
205 return self._target.format_map(settings)
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}
317@export
318class Shields(BaseDirective):
319 """
320 The ``shields`` directive: a project's badges, in rows.
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 """
327 directiveName: str = "shields" #: Name the directive is invoked by.
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 }
348 def run(self) -> list[nodes.Node]:
349 """
350 Render the badges named in the content, once for HTML and once for LaTeX.
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)]
361 classes = ["shields"] + self.options.get("class", "").split()
363 return [self._Only("html", rows, settings, classes), self._Only("latex", rows, settings, classes)]
365 @staticmethod
366 def _ParseRows(content: Iterable[str]) -> list[list[str]]:
367 """
368 Read the content: one row per line, identifiers separated by commas.
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)
380 if len(rows) == 0:
381 raise SphinxExtensionError("The directive's content names no badge.")
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}.")
389 return rows
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.
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}:'.")
406 @classmethod
407 def _Settings(cls, options: dict[str, str]) -> dict[str, str]:
408 """
409 Derive the values the badge URLs are formatted from.
411 An option that isn't stated derives nothing; :meth:`_CheckOptions` ensures that no badge needing it is drawn.
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] = {}
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>'.")
423 organization, repository = gitHub.split("/")
424 settings["GitHubOrganization"] = organization
425 settings["GitHubRepository"] = repository
427 if (pypi := options.get("pypi", None)) is not None:
428 settings["PyPI"] = pypi
430 if (codacy := options.get("codacy", None)) is not None:
431 settings["Codacy"] = codacy
433 if (gitter := options.get("gitter", None)) is not None:
434 settings["Gitter"] = gitter
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)}"
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 )
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"] = ""
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='')}&"
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 )
480 settings["DocumentationURL"] = url
481 settings["DocumentationLabel"] = quote(url.split("://", 1)[1].rstrip("/"), safe="")
482 settings["DocumentationQuery"] = quote(url, safe="")
483 settings["DocumentationLogo"] = logo
485 return settings
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.
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]
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
509 return expression, link
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.
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
527 raise SphinxExtensionError(f"Option ':{option}:' links to '{link}', neither 'github:<path>' nor a URL.")
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.
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:'.")
542 return f"{settings['GitHubOrganization']}/{settings['GitHubRepository']}"
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.
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="")
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.
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))
568 for row in rows:
569 block += (line := nodes.line())
570 for identifier in row:
571 line += self._Badge(SHIELDS[identifier], settings, latex)
573 return node
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.
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 )
592 if (target := shield.TargetURL(settings)) is None:
593 return image
595 reference = nodes.reference("", refuri=target)
596 reference += image
598 return reference