Shields.py
Summary
- Lines:
165 of 187 covered (88.2%)
- Branches:
70 of 76 taken (92.1%), 0 lines partially
- Coverage:
89.4%
- Directory:
- Missed lines:
354-363, 566-575, 587-600
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
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/{GitHubOrganizationBadge}-{GitHubRepositoryBadge}-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 settings["GitHubOrganizationBadge"] = cls._EscapeBadgeText(organization)
427 settings["GitHubRepositoryBadge"] = cls._EscapeBadgeText(repository)
428
429 if (pypi := options.get("pypi", None)) is not None:
430 settings["PyPI"] = pypi
431
432 if (codacy := options.get("codacy", None)) is not None:
433 settings["Codacy"] = codacy
434
435 if (gitter := options.get("gitter", None)) is not None:
436 settings["Gitter"] = gitter
437
438 if (sourceLicense := options.get("source-license", None)) is not None:
439 expression, link = cls._SplitLicense("source-license", sourceLicense)
440 settings["SourceLicenseURL"] = cls._ResolveLink("source-license", link, settings)
441 if expression is not None:
442 settings["SourceLicenseImage"] = f"badge/code-{cls._EscapeBadgeText(str(expression))}-blue"
443 elif "PyPI" in settings:
444 settings["SourceLicenseImage"] = f"pypi/l/{settings['PyPI']}"
445 else:
446 settings["SourceLicenseImage"] = f"github/license/{cls._GitHubSlug('source-license', settings)}"
447
448 if (documentationLicense := options.get("documentation-license", None)) is not None:
449 expression, link = cls._SplitLicense("documentation-license", documentationLicense)
450 if expression is None:
451 raise SphinxExtensionError(
452 "Option ':documentation-license:' states no license. Nothing reports a documentation's license, so "
453 "it is written before the link: '<SPDX expression> <link>'."
454 )
455
456 settings["DocumentationLicenseURL"] = cls._ResolveLink("documentation-license", link, settings)
457 settings["DocumentationLicenseBadge"] = cls._EscapeBadgeText(str(expression))
458 identifiers = [term.Identifier for term in expression.IterateExpression() if isinstance(term, BaseLicense)]
459 if all(identifier.startswith("CC") for identifier in identifiers):
460 settings["DocumentationLicenseLogo"] = "&logo=CreativeCommons&logoColor=fff"
461 else:
462 settings["DocumentationLicenseLogo"] = ""
463
464 if (gitHubAction := options.get("github-action", None)) is not None:
465 workflow, _, branch = gitHubAction.partition("@")
466 settings["Workflow"] = workflow
467 settings["WorkflowBranch"] = "" if branch == "" else f"branch={quote(branch, safe='')}&"
468
469 if (documentation := options.get("documentation", None)) is not None:
470 if documentation == "github-pages":
471 organization, repository = cls._GitHubSlug("documentation", settings).split("/")
472 url = f"https://{organization}.github.io/{repository}/"
473 logo = "&logo=GitHub&logoColor=fff"
474 elif documentation.startswith(("http://", "https://")):
475 url = documentation
476 logo = "&logo=ReadTheDocs&logoColor=fff" if ".readthedocs." in documentation else ""
477 else:
478 raise SphinxExtensionError(
479 f"Option ':documentation:' is '{documentation}', neither 'github-pages' nor an 'http(s)://' URL."
480 )
481
482 settings["DocumentationURL"] = url
483 settings["DocumentationLabel"] = quote(url.split("://", 1)[1].rstrip("/"), safe="")
484 settings["DocumentationQuery"] = quote(url, safe="")
485 settings["DocumentationLogo"] = logo
486
487 return settings
488
489 @staticmethod
490 def _SplitLicense(option: str, value: str) -> tuple[Nullable[LicenseExpression], str]:
491 """
492 Split a license option into the license and the link, which is its last word.
493
494 :param option: The option's name, for the message.
495 :param value: The option's value, ``[<SPDX expression> ]<link>``.
496 :returns: The parsed license expression, or ``None`` if none is stated, and the link.
497 :raises SphinxExtensionError: If the license isn't an SPDX license expression.
498 """
499 words = value.rsplit(maxsplit=1)
500 if len(words) == 1:
501 return None, words[0]
502
503 text, link = words
504 try:
505 expression = LicenseExpression.Parse(text)
506 except LicenseExpressionError as cause:
507 ex = SphinxExtensionError(f"Option ':{option}:' states '{text}', which isn't an SPDX license expression.")
508 ex.add_note(str(cause))
509 raise ex from cause
510
511 return expression, link
512
513 @classmethod
514 def _ResolveLink(cls, option: str, link: str, settings: dict[str, str]) -> str:
515 """
516 Resolve a link written as ``github:<path>`` or as a URL.
517
518 :param option: The option's name, for the message.
519 :param link: The link as written.
520 :param settings: The settings derived so far, which hold the repository ``:github:`` names.
521 :returns: The link's URL.
522 :raises SphinxExtensionError: If the link is neither form, or is ``github:`` without ``:github:``.
523 """
524 if link.startswith("github:"):
525 return f"https://GitHub.com/{cls._GitHubSlug(option, settings)}/blob/HEAD/{link.removeprefix('github:')}"
526 elif link.startswith(("http://", "https://")):
527 return link
528
529 raise SphinxExtensionError(f"Option ':{option}:' links to '{link}', neither 'github:<path>' nor a URL.")
530
531 @staticmethod
532 def _GitHubSlug(option: str, settings: dict[str, str]) -> str:
533 """
534 Return the repository ``:github:`` names, for an option referring to it.
535
536 :param option: The option's name, for the message.
537 :param settings: The settings derived so far.
538 :returns: ``<organization>/<repository>``.
539 :raises SphinxExtensionError: If ``:github:`` isn't stated.
540 """
541 if "GitHubOrganization" not in settings:
542 raise SphinxExtensionError(f"Option ':{option}:' refers to the repository, which needs option ':github:'.")
543
544 return f"{settings['GitHubOrganization']}/{settings['GitHubRepository']}"
545
546 @staticmethod
547 def _EscapeBadgeText(text: str) -> str:
548 """
549 Escape text for a static badge's path, where ``-`` and ``_`` separate the badge's fields.
550
551 :param text: The text to escape.
552 :returns: The text with ``-`` and ``_`` doubled and everything else a URL path can't hold percent-encoded.
553 """
554 return quote(text.replace("-", "--").replace("_", "__"), safe="")
555
556 def _Only(self, expression: str, rows: list[list[str]], settings: dict[str, str], classes: list[str]) -> only:
557 """
558 Build one builder-specific variant of the badges.
559
560 :param expression: The ``only`` expression selecting the builder, ``html`` or ``latex``.
561 :param rows: The identifiers, grouped into rows.
562 :param settings: The settings derived from the directive's options, filling the URLs.
563 :param classes: CSS classes to put on the block.
564 :returns: An ``only`` node holding a line per row.
565 """
566 latex = expression == "latex"
567 node = only("", expr=expression)
568 node += (block := nodes.line_block(classes=classes))
569
570 for row in rows:
571 block += (line := nodes.line())
572 for identifier in row:
573 line += self._Badge(SHIELDS[identifier], settings, latex)
574
575 return node
576
577 @staticmethod
578 def _Badge(shield: Shield, settings: dict[str, str], latex: bool) -> nodes.Node:
579 """
580 Build one badge: an image, wrapped in a reference when it links somewhere.
581
582 :param shield: The badge to build.
583 :param settings: The settings derived from the directive's options, filling the URLs.
584 :param latex: Whether this is the LaTeX variant.
585 :returns: The image, or the reference holding it.
586 """
587 image = nodes.image(
588 "",
589 uri=shield.ImageURL(settings, latex),
590 alt=shield.AlternativeText,
591 height=str(BADGE_HEIGHT)
592 )
593
594 if (target := shield.TargetURL(settings)) is None:
595 return image
596
597 reference = nodes.reference("", refuri=target)
598 reference += image
599
600 return reference