1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
|
# ==================================================================================================================== #
# _____ _ _ ____ _ _ #
# _ __ _ |_ _|__ ___ | (_)_ __ __ _ / ___| _ __ | |__ (_)_ __ __ __ #
# | '_ \| | | || |/ _ \ / _ \| | | '_ \ / _` | \___ \| '_ \| '_ \| | '_ \\ \/ / #
# | |_) | |_| || | (_) | (_) | | | | | | (_| |_ ___) | |_) | | | | | | | |> < #
# | .__/ \__, ||_|\___/ \___/|_|_|_| |_|\__, (_)____/| .__/|_| |_|_|_| |_/_/\_\ #
# |_| |___/ |___/ |_| #
# ==================================================================================================================== #
# Authors: #
# Patrick Lehmann #
# #
# License: #
# ==================================================================================================================== #
# Copyright 2026-2026 Patrick Lehmann - Bötzingen, Germany #
# #
# Licensed under the Apache License, Version 2.0 (the "License"); #
# you may not use this file except in compliance with the License. #
# You may obtain a copy of the License at #
# #
# http://www.apache.org/licenses/LICENSE-2.0 #
# #
# Unless required by applicable law or agreed to in writing, software #
# distributed under the License is distributed on an "AS IS" BASIS, #
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. #
# See the License for the specific language governing permissions and #
# limitations under the License. #
# #
# SPDX-License-Identifier: Apache-2.0 #
# ==================================================================================================================== #
"""
A Sphinx directive rendering a project's badges.
A landing page shows where a project lives, how it is licensed, whether it builds and where it is published, as a
few rows of `shields.io <https://shields.io/>`__ badges. The directive states the project's coordinates as options
and the badges as its content:
.. code-block:: ReST
.. shields::
:github: pyTooling/pyTooling
:pypi: pyTooling
:codacy: 08ef744c0b70490289712b02a7a4cebe
:source-license: github:LICENSE.md
:documentation-license: CC-BY-4.0 github:doc/License.rst
:github-action: Pipeline.yml@main
:documentation: github-pages
github, src-license, ghp-doc, doc-license
pypi-tag, pypi-status, pypi-python
github-action, lib-status, codacy-quality, codacy-coverage, codecov-coverage
**The content is the layout**: badges appear in the order written, and a new line starts a new row. A badge needs
only the options its URLs are made of - :data:`SHIELDS` names them per badge - so a project states what its badges
use and nothing else.
.. rubric:: Links
``:source-license:`` and ``:documentation-license:`` link to where the license is written:
* ``github:<path>`` is a file in the repository ``:github:`` names, on its default branch.
* ``http://…`` or ``https://…`` is used as written.
.. rubric:: Licenses
PyPI reports a package's license, and GitHub a repository's, so ``:source-license:`` needs only the link - the badge
asks PyPI when ``:pypi:`` is stated, and GitHub otherwise. An SPDX expression before the link replaces what they
report. Nothing reports a documentation's license, so ``:documentation-license:`` states it
first. Both are parsed with :meth:`~pyTooling.Licensing.LicenseExpression.Parse`, so a license outside the SPDX list
is written as ``LicenseRef-<name>``.
.. rubric:: HTML and LaTeX
Both variants are emitted, each wrapped in an :rst:dir:`only` node: HTML embeds the SVG from ``img.shields.io``,
LaTeX the PNG from ``raster.shields.io``, because a PDF cannot embed an SVG.
.. seealso::
:ref:`DIR/Shields`
|rarr| The directive's options and badges, with a rendered example.
:mod:`pyTooling.Sphinx`
|rarr| The extension this belongs to, and what else it brings.
"""
from typing import Any, Iterable, Optional as Nullable
from urllib.parse import quote
from docutils import nodes
from sphinx.addnodes import only
from pyTooling.Common import getFullyQualifiedName
from pyTooling.Decorators import export, readonly
from pyTooling.Licensing import BaseLicense, LicenseExpression, LicenseExpressionError
from pyTooling.MetaClasses import ExtendedType
from pyTooling.Sphinx import BaseDirective, SphinxExtensionError, strip
__all__ = ["SHIELDS_SERVICE_SVG", "SHIELDS_SERVICE_PNG", "BADGE_HEIGHT", "SHIELDS"]
#: Host serving a badge as SVG, which is what an HTML build embeds.
SHIELDS_SERVICE_SVG = "https://img.shields.io"
#: Host serving the same badge rasterized, which is what a LaTeX build needs - a PDF cannot embed the SVG.
SHIELDS_SERVICE_PNG = "https://raster.shields.io"
#: Height every badge is scaled to, in pixels.
BADGE_HEIGHT = 22
@export
class Shield(metaclass=ExtendedType, slots=True):
"""
One badge: where its image comes from, what it links to, what a screen reader is told, and which of the
directive's options it is made of.
The image is stated as the part of the URL **after** the host, because the host is the only difference between
the SVG an HTML build embeds and the PNG a LaTeX build needs. Image and target are formatted from the settings the
directive derives from its options, so a placeholder such as ``{GitHubOrganization}`` is filled per page.
"""
_alternativeText: str #: What the image says when it can't be shown.
_path: str #: Path and query on the badge host, with placeholders to format.
_target: Nullable[str] #: What the badge links to; ``None`` for a badge that links nowhere.
_options: tuple[str, ...] #: Names of the directive's options the badge is made of.
def __init__(
self,
alternativeText: str,
path: str,
target: Nullable[str] = None,
options: Nullable[Iterable[str]] = None
) -> None:
"""
Describe a badge.
:param alternativeText: What the image says when it can't be shown.
:param path: Path and query on the badge host, with placeholders to format.
:param target: Optional, what the badge links to.
:param options: Optional, names of the directive's options the badge is made of.
:raises ValueError: If parameter 'alternativeText' or 'path' is None.
:raises TypeError: If parameter 'alternativeText', 'path' or 'target' is not a string.
"""
if alternativeText is None:
raise ValueError("Parameter 'alternativeText' is None.")
elif not isinstance(alternativeText, str):
ex = TypeError("Parameter 'alternativeText' is not of type 'str'.")
ex.add_note(f"Got type '{getFullyQualifiedName(alternativeText)}'.")
raise ex
if path is None:
raise ValueError("Parameter 'path' is None.")
elif not isinstance(path, str):
ex = TypeError("Parameter 'path' is not of type 'str'.")
ex.add_note(f"Got type '{getFullyQualifiedName(path)}'.")
raise ex
if target is not None and not isinstance(target, str):
ex = TypeError("Parameter 'target' is not of type 'str'.")
ex.add_note(f"Got type '{getFullyQualifiedName(target)}'.")
raise ex
self._alternativeText = alternativeText
self._path = path
self._target = target
self._options = () if options is None else tuple(options)
@readonly
def AlternativeText(self) -> str:
"""
Read-only property to access what the image says when it can't be shown.
:returns: The badge's alternative text.
"""
return self._alternativeText
@readonly
def Options(self) -> tuple[str, ...]:
"""
Read-only property to access the names of the directive's options the badge is made of.
:returns: The option names, without colons.
"""
return self._options
def ImageURL(self, settings: dict[str, str], latex: bool) -> str:
"""
Format the badge's image URL.
:param settings: The settings derived from the directive's options, which the path's placeholders name.
:param latex: Whether the URL is for a LaTeX build, which needs the rasterized badge.
:returns: The complete URL of the badge image.
"""
host = SHIELDS_SERVICE_PNG if latex else SHIELDS_SERVICE_SVG
return f"{host}/{self._path.format_map(settings)}"
def TargetURL(self, settings: dict[str, str]) -> Nullable[str]:
"""
Format what the badge links to.
:param settings: The settings derived from the directive's options, which the target's placeholders name.
:returns: The complete target URL, or ``None`` when the badge links nowhere.
"""
if self._target is None:
return None
return self._target.format_map(settings)
#: Every badge the directive knows, keyed by the identifier its content names.
SHIELDS: dict[str, Shield] = {
"github": Shield(
"Sourcecode on GitHub",
"badge/{GitHubOrganization}-{GitHubRepository}-63bf7f?longCache=true&style=flat-square&longCache=true"
"&logo=GitHub",
"https://GitHub.com/{GitHubOrganization}/{GitHubRepository}",
("github",)
),
"src-license": Shield(
"Code license",
"{SourceLicenseImage}?longCache=true&style=flat-square&label=code",
"{SourceLicenseURL}",
("source-license",)
),
"doc-license": Shield(
"Documentation License",
"badge/doc-{DocumentationLicenseBadge}-green?longCache=true&style=flat-square{DocumentationLicenseLogo}",
"{DocumentationLicenseURL}",
("documentation-license",)
),
"ghp-doc": Shield(
"Documentation - Read Now!",
"website?longCache=true&style=flat-square&label={DocumentationLabel}{DocumentationLogo}"
"&up_color=blueviolet&up_message=Read%20now%20%E2%9E%9A&url={DocumentationQuery}",
"{DocumentationURL}",
("documentation",)
),
"tag": Shield(
"GitHub tag (latest SemVer incl. pre-release)",
"github/v/tag/{GitHubOrganization}/{GitHubRepository}?longCache=true&style=flat-square&logo=GitHub"
"&include_prereleases",
"https://GitHub.com/{GitHubOrganization}/{GitHubRepository}/tags",
("github",)
),
"date": Shield(
"GitHub release date",
"github/release-date/{GitHubOrganization}/{GitHubRepository}?longCache=true&style=flat-square&logo=GitHub",
"https://GitHub.com/{GitHubOrganization}/{GitHubRepository}/releases",
("github",)
),
"github-action": Shield(
"GitHub Workflow - Build and Test Status",
"github/actions/workflow/status/{GitHubOrganization}/{GitHubRepository}/{Workflow}?{WorkflowBranch}"
"longCache=true&style=flat-square&label=Build%20and%20Test&logo=GitHub%20Actions&logoColor=FFFFFF",
"https://GitHub.com/{GitHubOrganization}/{GitHubRepository}/actions/workflows/{Workflow}",
("github", "github-action")
),
"codacy-quality": Shield(
"Codacy - Quality",
"codacy/grade/{Codacy}?longCache=true&style=flat-square&logo=codacy",
"https://app.codacy.com/gh/{GitHubOrganization}/{GitHubRepository}/dashboard",
("github", "codacy")
),
"codacy-coverage": Shield(
"Codacy - Line Coverage",
"codacy/coverage/{Codacy}?longCache=true&style=flat-square&logo=codacy",
"https://app.codacy.com/gh/{GitHubOrganization}/{GitHubRepository}/dashboard",
("github", "codacy")
),
"codecov-coverage": Shield(
"Codecov - Branch Coverage",
"codecov/c/github/{GitHubOrganization}/{GitHubRepository}?longCache=true&style=flat-square&logo=Codecov",
"https://codecov.io/gh/{GitHubOrganization}/{GitHubRepository}",
("github",)
),
"lib-status": Shield(
"Libraries.io status for latest release",
"librariesio/release/pypi/{PyPI}?longCache=true&style=flat-square&logo=Libraries.io&logoColor=fff",
"https://libraries.io/pypi/{PyPI}",
("pypi",)
),
"lib-rank": Shield(
"Libraries.io SourceRank",
"librariesio/sourcerank/pypi/{PyPI}?longCache=true&style=flat-square&logo=Libraries.io&logoColor=fff",
"https://libraries.io/pypi/{PyPI}/sourcerank",
("pypi",)
),
"lib-dep": Shield(
"Dependent repos (via libraries.io)",
"librariesio/dependent-repos/pypi/{PyPI}?longCache=true&style=flat-square&logo=Libraries.io&logoColor=fff",
"https://GitHub.com/{GitHubOrganization}/{GitHubRepository}/network/dependents",
("github", "pypi")
),
"pypi-tag": Shield(
"PyPI - Tag",
"pypi/v/{PyPI}?longCache=true&style=flat-square&logo=PyPI&logoColor=FBE072",
"https://pypi.org/project/{PyPI}/",
("pypi",)
),
"pypi-status": Shield(
"PyPI - Status",
"pypi/status/{PyPI}?longCache=true&style=flat-square&logo=PyPI&logoColor=FBE072",
options=("pypi",)
),
"pypi-python": Shield(
"PyPI - Python Version",
"pypi/pyversions/{PyPI}?longCache=true&style=flat-square&logo=PyPI&logoColor=FBE072",
options=("pypi",)
),
"gitter": Shield(
"Chat on gitter",
"badge/chat-on%20gitter-4db797?longCache=true&style=flat-square&logo=gitter&logoColor=e8ecef",
"https://gitter.im/{Gitter}",
("gitter",)
),
}
@export
class Shields(BaseDirective):
"""
The ``shields`` directive: a project's badges, in rows.
The options state the project's coordinates, the content names the badges - one line per row, identifiers
separated by commas - and :data:`SHIELDS` is what the identifiers name. ``:class:`` puts additional CSS classes on
the rows.
"""
directiveName: str = "shields" #: Name the directive is invoked by.
has_content = True #: A boolean; ``True`` if content is allowed.
required_arguments = 0 #: Number of required directive arguments.
optional_arguments = 0 #: Number of optional arguments after the required ones.
final_argument_whitespace = False #: A boolean; ``True`` if the last argument may contain spaces.
# docutils declares 'option_spec' on 'Directive' and 'BaseDirective' assigns it, so mypy calls every
# spelling of this override a conflict with one of them
#: Mapping of option names to validator functions.
option_spec: dict[str, Any] = { # type: ignore[misc]
"github": strip,
"pypi": strip,
"codacy": strip,
"gitter": strip,
"source-license": strip,
"documentation-license": strip,
"github-action": strip,
"documentation": strip,
"class": strip,
}
def run(self) -> list[nodes.Node]:
"""
Render the badges named in the content, once for HTML and once for LaTeX.
:returns: Two ``only`` nodes, or the message of whatever the options or the content got wrong.
"""
try:
rows = self._ParseRows(self.content)
self._CheckOptions(rows, self.options)
settings = self._Settings(self.options)
except SphinxExtensionError as ex:
return [self.state.document.reporter.error(f"{self.directiveName}: {ex}", line=self.lineno)]
classes = ["shields"] + self.options.get("class", "").split()
return [self._Only("html", rows, settings, classes), self._Only("latex", rows, settings, classes)]
@staticmethod
def _ParseRows(content: Iterable[str]) -> list[list[str]]:
"""
Read the content: one row per line, identifiers separated by commas.
:param content: The directive's content, line by line.
:returns: The identifiers, grouped into the rows they were written in.
:raises SphinxExtensionError: If the content is empty, or names a badge :data:`SHIELDS` doesn't know.
"""
rows = []
for line in content:
identifiers = [identifier.strip() for identifier in line.split(",") if identifier.strip() != ""]
if len(identifiers) > 0:
rows.append(identifiers)
if len(rows) == 0:
raise SphinxExtensionError("The directive's content names no badge.")
for row in rows:
for identifier in row:
if identifier not in SHIELDS:
known = ", ".join(sorted(SHIELDS))
raise SphinxExtensionError(f"'{identifier}' is not a known badge. Known are: {known}.")
return rows
@staticmethod
def _CheckOptions(rows: list[list[str]], options: dict[str, str]) -> None:
"""
Check that every badge named has the options it is made of.
:param rows: The identifiers, grouped into rows.
:param options: The directive's options.
:raises SphinxExtensionError: If a badge needs an option the directive doesn't state.
"""
for row in rows:
for identifier in row:
for option in SHIELDS[identifier].Options:
if option not in options:
raise SphinxExtensionError(f"Badge '{identifier}' needs option ':{option}:'.")
@classmethod
def _Settings(cls, options: dict[str, str]) -> dict[str, str]:
"""
Derive the values the badge URLs are formatted from.
An option that isn't stated derives nothing; :meth:`_CheckOptions` ensures that no badge needing it is drawn.
:param options: The directive's options.
:returns: The values, keyed by the placeholders :data:`SHIELDS` names.
:raises SphinxExtensionError: If an option's value is malformed, or refers to ``:github:`` without it.
"""
settings: dict[str, str] = {}
if (gitHub := options.get("github", None)) is not None:
if gitHub.count("/") != 1:
raise SphinxExtensionError(f"Option ':github:' is '{gitHub}', not '<organization>/<repository>'.")
organization, repository = gitHub.split("/")
settings["GitHubOrganization"] = organization
settings["GitHubRepository"] = repository
if (pypi := options.get("pypi", None)) is not None:
settings["PyPI"] = pypi
if (codacy := options.get("codacy", None)) is not None:
settings["Codacy"] = codacy
if (gitter := options.get("gitter", None)) is not None:
settings["Gitter"] = gitter
if (sourceLicense := options.get("source-license", None)) is not None:
expression, link = cls._SplitLicense("source-license", sourceLicense)
settings["SourceLicenseURL"] = cls._ResolveLink("source-license", link, settings)
if expression is not None:
settings["SourceLicenseImage"] = f"badge/code-{cls._EscapeBadgeText(str(expression))}-blue"
elif "PyPI" in settings:
settings["SourceLicenseImage"] = f"pypi/l/{settings['PyPI']}"
else:
settings["SourceLicenseImage"] = f"github/license/{cls._GitHubSlug('source-license', settings)}"
if (documentationLicense := options.get("documentation-license", None)) is not None:
expression, link = cls._SplitLicense("documentation-license", documentationLicense)
if expression is None:
raise SphinxExtensionError(
"Option ':documentation-license:' states no license. Nothing reports a documentation's license, so "
"it is written before the link: '<SPDX expression> <link>'."
)
settings["DocumentationLicenseURL"] = cls._ResolveLink("documentation-license", link, settings)
settings["DocumentationLicenseBadge"] = cls._EscapeBadgeText(str(expression))
identifiers = [term.Identifier for term in expression.IterateExpression() if isinstance(term, BaseLicense)]
if all(identifier.startswith("CC") for identifier in identifiers):
settings["DocumentationLicenseLogo"] = "&logo=CreativeCommons&logoColor=fff"
else:
settings["DocumentationLicenseLogo"] = ""
if (gitHubAction := options.get("github-action", None)) is not None:
workflow, _, branch = gitHubAction.partition("@")
settings["Workflow"] = workflow
settings["WorkflowBranch"] = "" if branch == "" else f"branch={quote(branch, safe='')}&"
if (documentation := options.get("documentation", None)) is not None:
if documentation == "github-pages":
organization, repository = cls._GitHubSlug("documentation", settings).split("/")
url = f"https://{organization}.github.io/{repository}/"
logo = "&logo=GitHub&logoColor=fff"
elif documentation.startswith(("http://", "https://")):
url = documentation
logo = "&logo=ReadTheDocs&logoColor=fff" if ".readthedocs." in documentation else ""
else:
raise SphinxExtensionError(
f"Option ':documentation:' is '{documentation}', neither 'github-pages' nor an 'http(s)://' URL."
)
settings["DocumentationURL"] = url
settings["DocumentationLabel"] = quote(url.split("://", 1)[1].rstrip("/"), safe="")
settings["DocumentationQuery"] = quote(url, safe="")
settings["DocumentationLogo"] = logo
return settings
@staticmethod
def _SplitLicense(option: str, value: str) -> tuple[Nullable[LicenseExpression], str]:
"""
Split a license option into the license and the link, which is its last word.
:param option: The option's name, for the message.
:param value: The option's value, ``[<SPDX expression> ]<link>``.
:returns: The parsed license expression, or ``None`` if none is stated, and the link.
:raises SphinxExtensionError: If the license isn't an SPDX license expression.
"""
words = value.rsplit(maxsplit=1)
if len(words) == 1:
return None, words[0]
text, link = words
try:
expression = LicenseExpression.Parse(text)
except LicenseExpressionError as cause:
ex = SphinxExtensionError(f"Option ':{option}:' states '{text}', which isn't an SPDX license expression.")
ex.add_note(str(cause))
raise ex from cause
return expression, link
@classmethod
def _ResolveLink(cls, option: str, link: str, settings: dict[str, str]) -> str:
"""
Resolve a link written as ``github:<path>`` or as a URL.
:param option: The option's name, for the message.
:param link: The link as written.
:param settings: The settings derived so far, which hold the repository ``:github:`` names.
:returns: The link's URL.
:raises SphinxExtensionError: If the link is neither form, or is ``github:`` without ``:github:``.
"""
if link.startswith("github:"):
return f"https://GitHub.com/{cls._GitHubSlug(option, settings)}/blob/HEAD/{link.removeprefix('github:')}"
elif link.startswith(("http://", "https://")):
return link
raise SphinxExtensionError(f"Option ':{option}:' links to '{link}', neither 'github:<path>' nor a URL.")
@staticmethod
def _GitHubSlug(option: str, settings: dict[str, str]) -> str:
"""
Return the repository ``:github:`` names, for an option referring to it.
:param option: The option's name, for the message.
:param settings: The settings derived so far.
:returns: ``<organization>/<repository>``.
:raises SphinxExtensionError: If ``:github:`` isn't stated.
"""
if "GitHubOrganization" not in settings:
raise SphinxExtensionError(f"Option ':{option}:' refers to the repository, which needs option ':github:'.")
return f"{settings['GitHubOrganization']}/{settings['GitHubRepository']}"
@staticmethod
def _EscapeBadgeText(text: str) -> str:
"""
Escape text for a static badge's path, where ``-`` and ``_`` separate the badge's fields.
:param text: The text to escape.
:returns: The text with ``-`` and ``_`` doubled and everything else a URL path can't hold percent-encoded.
"""
return quote(text.replace("-", "--").replace("_", "__"), safe="")
def _Only(self, expression: str, rows: list[list[str]], settings: dict[str, str], classes: list[str]) -> only:
"""
Build one builder-specific variant of the badges.
:param expression: The ``only`` expression selecting the builder, ``html`` or ``latex``.
:param rows: The identifiers, grouped into rows.
:param settings: The settings derived from the directive's options, filling the URLs.
:param classes: CSS classes to put on the block.
:returns: An ``only`` node holding a line per row.
"""
latex = expression == "latex"
node = only("", expr=expression)
node += (block := nodes.line_block(classes=classes))
for row in rows:
block += (line := nodes.line())
for identifier in row:
line += self._Badge(SHIELDS[identifier], settings, latex)
return node
@staticmethod
def _Badge(shield: Shield, settings: dict[str, str], latex: bool) -> nodes.Node:
"""
Build one badge: an image, wrapped in a reference when it links somewhere.
:param shield: The badge to build.
:param settings: The settings derived from the directive's options, filling the URLs.
:param latex: Whether this is the LaTeX variant.
:returns: The image, or the reference holding it.
"""
image = nodes.image(
"",
uri=shield.ImageURL(settings, latex),
alt=shield.AlternativeText,
height=str(BADGE_HEIGHT)
)
if (target := shield.TargetURL(settings)) is None:
return image
reference = nodes.reference("", refuri=target)
reference += image
return reference
|