Source code for pyTooling.Sphinx.Shields

# ==================================================================================================================== #
#             _____           _ _               ____        _     _                                                    #
#  _ __  _   |_   _|__   ___ | (_)_ __   __ _  / ___| _ __ | |__ (_)_ __ __  __                                        #
# | '_ \| | | || |/ _ \ / _ \| | | '_ \ / _` | \___ \| '_ \| '_ \| | '_ \\ \/ /                                        #
# | |_) | |_| || | (_) | (_) | | | | | | (_| |_ ___) | |_) | | | | | | | |>  <                                         #
# | .__/ \__, ||_|\___/ \___/|_|_|_| |_|\__, (_)____/| .__/|_| |_|_|_| |_/_/\_\                                        #
# |_|    |___/                          |___/        |_|                                                               #
# ==================================================================================================================== #
# 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


[docs] @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.
[docs] 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
[docs] 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)}"
[docs] 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",) ), }
[docs] @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, }
[docs] 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)]
[docs] @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
[docs] @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}:'.")
[docs] @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
[docs] @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
[docs] @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']}"
[docs] @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="")
[docs] 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
[docs] @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