#!/usr/bin/env python3
#
# shields.py
r"""
Directives for shield/badge images.
.. extensions:: sphinx_toolbox.shields
Usage
---------
Several shield/badge directives are available.
They function similarly to the ``.. image::`` directives, although not all options are available.
As with the image directive, shields can be used as part of substitutions, e.g.
.. code-block:: rest
This repository uses pre-commit |pre-commit|
.. |pre-commit| pre-commit::
All shields have the following options:
.. rst:directive:option:: alt
Alternative text for the shield, used when the image cannot be displayed or the user uses a screen reader.
.. rst:directive:option:: height
width
scale
The height/width/scale of the shield.
.. rst:directive:option:: name
.. rst:directive:option:: class
:type: string
Additional CSS class for the shield.
All shields have the ``sphinx_toolbox_shield`` class by default.
.. latex:clearpage::
Shields
^^^^^^^^^^^^
.. rst:directive:: rtfd-shield
Shield to show the `ReadTheDocs <https://readthedocs.org/>`_ documentation build status.
.. rst:directive:option:: project
The name of the project on *ReadTheDocs*.
.. rst:directive:option:: version
The documentation version. Default ``latest``.
.. rst:directive:option:: target
The hyperlink target of the shield. Useful if the documentation uses a custom domain.
.. versionadded:: 1.8.0
.. only:: html
**Examples**
.. rest-example::
.. rtfd-shield::
:project: sphinx-toolbox
.. rest-example::
.. rtfd-shield::
:project: attrs
:target: https://www.attrs.org/
.. rst:directive:: pypi-shield
Shield to show information about the project on `PyPI <https://pypi.org/>`_.
.. rst:directive:option:: project
The name of the project on *PyPI*.
Only one of the following options is permitted:
.. rst:directive:option:: version
Show the package version.
.. rst:directive:option:: py-versions
Show the supported python versions.
.. rst:directive:option:: implementations
Show the supported python implementations.
.. rst:directive:option:: wheel
Show whether the package has a wheel.
.. rst:directive:option:: license
Show the license listed on PyPI.
.. rst:directive:option:: downloads
Show the downloads for the given period (day / week / month)
.. versionchanged:: 2.5.0 Shields created with this option now link to pypistats_.
.. _pypistats: https://pypistats.org
.. only:: html
**Examples**
.. rest-example::
.. pypi-shield::
:version:
\
.. pypi-shield::
:project: sphinx
:downloads: month
.. rst:directive:: maintained-shield
Shield to indicate whether the project is maintained.
Takes a single argument: the current year.
.. only:: html
**Example**
.. rest-example::
.. maintained-shield:: 2020
.. rst:directive:: github-shield
Shield to show information about a GitHub repository.
.. rst:directive:option:: username
The GitHub username. Defaults to :confval:`github_username`.
.. rst:directive:option:: repository
The GitHub repository. Defaults to :confval:`github_repository`.
.. rst:directive:option:: branch
The branch to show information about. Default ``master``.
Required for ``commits-since`` and ``last-commit``.
Only one of the following options is permitted:
.. rst:directive:option:: contributors
:type: flag
Show the number of contributors.
.. rst:directive:option:: commits-since: tag
:type: string
Show the number of commits since the given tag.
.. rst:directive:option:: last-commit
:type: flag
Show the date of the last commit.
.. rst:directive:option:: top-language
:type: flag
Show the top language and percentage.
.. rst:directive:option:: license
:type: flag
Show the license detected by GitHub.
.. only:: html
**Examples**
.. rest-example::
.. github-shield::
:last-commit:
\
.. github-shield::
:commits-since: v0.1.0
.. rst:directive:: actions-shield
Shield to show the *GitHub Actions* build status.
.. rst:directive:option:: username
The GitHub username. Defaults to :confval:`github_username`.
.. rst:directive:option:: repository
The GitHub repository. Defaults to :confval:`github_repository`.
.. rst:directive:option:: workflow
The workflow to show the status for.
.. only:: html
**Example**
.. rest-example::
.. actions-shield::
:workflow: Windows Tests
.. rst:directive:: requires-io-shield
Shield to show the *Requires.io* status.
.. rst:directive:option:: username
The GitHub username. Defaults to :confval:`github_username`.
.. rst:directive:option:: repository
The GitHub repository. Defaults to :confval:`github_repository`.
.. rst:directive:option:: branch
The branch to show the build status for. Default ``master``.
.. only:: html
**Example**
.. rest-example::
.. requires-io-shield::
.. rst:directive:: coveralls-shield
Shield to show the code coverage from `Coveralls.io <https://coveralls.io/>`_.
.. rst:directive:option:: username
The GitHub username. Defaults to :confval:`github_username`.
.. rst:directive:option:: repository
The GitHub repository. Defaults to :confval:`github_repository`.
.. rst:directive:option:: branch
The branch to show the build status for. Default ``master``.
.. only:: html
**Example**
.. rest-example::
.. coveralls-shield::
.. rst:directive:: codefactor-shield
Shield to show the code quality score from `Codefactor <https://www.codefactor.io>`_.
.. rst:directive:option:: username
The GitHub username. Defaults to :confval:`github_username`.
.. rst:directive:option:: repository
The GitHub repository. Defaults to :confval:`github_repository`.
.. only:: html
**Example**
.. rest-example::
.. codefactor-shield::
.. rst:directive:: pre-commit-shield
Shield to indicate that the project uses `pre-commit <https://pre-commit.com/>`_.
.. only:: html
**Example**
.. rest-example::
.. pre-commit-shield::
.. rst:directive:: pre-commit-ci-shield
.. versionadded:: 1.7.0
Shield to show the `pre-commit.ci <https://pre-commit.ci/>`_ status.
.. rst:directive:option:: username
The GitHub username. Defaults to :confval:`github_username`.
.. rst:directive:option:: repository
The GitHub repository. Defaults to :confval:`github_repository`.
.. rst:directive:option:: branch
The branch to show the status for. Default ``master``.
.. only:: html
**Example**
.. rest-example::
.. pre-commit-ci-shield::
.. latex:vspace:: 5mm
"""
#
# Copyright © 2020-2021 Dominic Davis-Foster <dominic@davis-foster.co.uk>
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
# DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
# OR OTHER DEALINGS IN THE SOFTWARE.
#
# Based on public domain code from Docutils
#
# stdlib
from typing import List, Optional, Tuple
from urllib.parse import quote
# 3rd party
import dict2css
import docutils
from apeye.url import URL
from docutils import nodes
from docutils.nodes import fully_normalize_name, whitespace_normalize_name
from docutils.parsers.rst import directives
from docutils.parsers.rst.roles import set_classes
from domdf_python_tools.paths import PathPlus
from sphinx.application import Sphinx
from sphinx.util.docutils import SphinxDirective
# this package
from sphinx_toolbox import _css
from sphinx_toolbox.utils import OptionSpec, SphinxExtMetadata, flag, make_github_url, metadata_add_version
__all__ = (
"SHIELDS_IO",
"shield_default_option_spec",
"Shield",
"RTFDShield",
"PyPIShield",
"MaintainedShield",
"GitHubBackedShield",
"GitHubShield",
"GitHubActionsShield",
"RequiresIOShield",
"CoverallsShield",
"CodefactorShield",
"PreCommitShield",
"PreCommitCIShield",
"copy_asset_files",
"setup",
)
#: Base URL for shields.io
SHIELDS_IO = URL("https://img.shields.io")
#: Options common to all shields.
shield_default_option_spec: OptionSpec = {
"alt": directives.unchanged,
"height": directives.length_or_unitless,
"width": directives.length_or_percentage_or_unitless,
"scale": directives.percentage,
"name": directives.unchanged,
"class": directives.class_option,
}
[docs]class Shield(SphinxDirective):
"""
Directive for `shields.io <https://shields.io>`_ shields/badges.
"""
required_arguments = 1
optional_arguments = 0
final_argument_whitespace = True
option_spec: OptionSpec = { # type: ignore[assignment]
"target": directives.unchanged_required,
**shield_default_option_spec,
}
def run(self) -> List[nodes.Node]:
"""
Process the content of the shield directive.
"""
if "class" in self.options:
self.options["class"].append("sphinx_toolbox_shield")
else:
self.options["class"] = ["sphinx_toolbox_shield"]
self.arguments = [str(x) for x in self.arguments]
messages = []
reference = directives.uri(self.arguments[0])
self.options["uri"] = reference
reference_node = None
if "target" in self.options:
block = docutils.utils.escape2null(self.options["target"]).splitlines()
block = [line for line in block]
target_type, data = self.state.parse_target(block, self.block_text, self.lineno) # type: ignore[attr-defined]
if target_type == "refuri":
reference_node = nodes.reference(refuri=data)
elif target_type == "refname": # pragma: no cover
reference_node = nodes.reference(
refname=fully_normalize_name(data),
name=whitespace_normalize_name(data),
)
reference_node.indirect_reference_name = data # type: ignore[attr-defined]
self.state.document.note_refname(reference_node)
else: # pragma: no cover
# malformed target
# data is a system message
messages.append(data)
del self.options["target"]
set_classes(self.options)
image_node = nodes.image(self.block_text, **self.options)
self.add_name(image_node)
if reference_node:
reference_node += image_node
return messages + [reference_node]
else:
return messages + [image_node]
[docs]class GitHubBackedShield(Shield):
"""
Base class for badges that are based around GitHub.
"""
required_arguments = 0
option_spec: OptionSpec = {
"username": str, # Defaults to "github_username" if undefined
"repository": str, # Defaults to "github_repository" if undefined
**shield_default_option_spec,
}
[docs] def get_repo_details(self) -> Tuple[str, str]:
"""
Returns the username and repository name, either parsed from the directive's options or from ``conf.py``.
"""
username = self.options.pop("username", self.env.config.github_username)
if username is None:
raise ValueError("'github_username' has not been set in 'conf.py'!")
repository = self.options.pop("repository", self.env.config.github_repository)
if repository is None:
raise ValueError("'github_repository' has not been set in 'conf.py'!")
return username, repository
[docs]class RTFDShield(Shield):
"""
Shield to show the `ReadTheDocs <https://readthedocs.org/>`_ documentation build status.
.. versionchanged:: 1.8.0
Added the ``:target:`` option, to allow a custom target to be specified.
Useful if the documentation uses a custom domain.
"""
required_arguments = 0
option_spec: OptionSpec = {
"project": directives.unchanged_required,
"version": str,
"target": str,
**shield_default_option_spec,
}
def run(self) -> List[nodes.Node]:
"""
Process the content of the shield directive.
"""
project = self.options.pop("project")
version = self.options.pop("version", "latest")
image = SHIELDS_IO / "readthedocs" / project / f"{version}?logo=read-the-docs"
self.arguments = [image]
if "target" not in self.options:
self.options["target"] = f"https://{project}.readthedocs.io/en/{version}/"
return super().run()
[docs]class GitHubActionsShield(GitHubBackedShield):
"""
Shield to show the *GitHub Actions* build status.
"""
option_spec: OptionSpec = {
"username": str, # Defaults to "github_username" if undefined
"repository": str, # Defaults to "github_repository" if undefined
"workflow": directives.unchanged_required, # The name of the workflow
**shield_default_option_spec,
}
def run(self) -> List[nodes.Node]:
"""
Process the content of the shield directive.
"""
username, repository = self.get_repo_details()
workflow = quote(self.options["workflow"])
self.arguments = [str(make_github_url(username, repository) / "workflows" / workflow / "badge.svg")]
self.options["target"] = str(
make_github_url(username, repository) / f"actions?query=workflow%3A%22{workflow}%22"
)
return super().run()
[docs]class RequiresIOShield(GitHubBackedShield):
"""
Shield to show the `Requires.io <https://requires.io>`_ status.
"""
option_spec: OptionSpec = {
"username": str, # Defaults to "github_username" if undefined
"repository": str, # Defaults to "github_repository" if undefined
"branch": str,
**shield_default_option_spec,
}
def run(self) -> List[nodes.Node]:
"""
Process the content of the shield directive.
"""
username, repository = self.get_repo_details()
branch = self.options.pop("branch", "master")
base_url = URL("https://requires.io/github/") / username / repository
self.arguments = [str(base_url / f"requirements.svg?branch={branch}")]
self.options["target"] = str(base_url / f"requirements/?branch={branch}")
return super().run()
[docs]class CoverallsShield(GitHubBackedShield):
"""
Shield to show the code coverage from `Coveralls.io <https://coveralls.io/>`_.
"""
option_spec: OptionSpec = {
"username": str, # Defaults to "github_username" if undefined
"repository": str, # Defaults to "github_repository" if undefined
"branch": str,
**shield_default_option_spec,
}
def run(self) -> List[nodes.Node]:
"""
Process the content of the shield directive.
"""
username, repository = self.get_repo_details()
branch = self.options.pop("branch", "master")
url = SHIELDS_IO / "coveralls" / "github" / username / repository / f"{branch}?logo=coveralls"
self.arguments = [str(url)]
self.options["target"] = f"https://coveralls.io/github/{username}/{repository}?branch={branch}"
return super().run()
[docs]class CodefactorShield(GitHubBackedShield):
"""
Shield to show the code quality score from `Codefactor <https://www.codefactor.io>`_.
"""
def run(self) -> List[nodes.Node]:
"""
Process the content of the shield directive.
"""
username, repository = self.get_repo_details()
url = SHIELDS_IO / "codefactor" / "grade" / "github" / username / f"{repository}?logo=codefactor"
self.arguments = [str(url)]
self.options["target"] = f"https://codefactor.io/repository/github/{username}/{repository}"
return super().run()
[docs]class PyPIShield(Shield):
"""
Shield to show information about the project on `PyPI <https://pypi.org/>`_.
"""
required_arguments = 0
option_spec: OptionSpec = {
"project": directives.unchanged_required,
"version": flag, # Show the package version.
"py-versions": flag, # Show the supported python versions.
"implementations": flag, # Show the supported python implementations.
"wheel": flag, # Show whether the package has a wheel on PyPI.
"license": flag, # Show the license listed on PyPI.
"downloads": str, # Show the downloads for the given period (day / week / month).
**shield_default_option_spec,
}
def run(self) -> List[nodes.Node]:
"""
Process the content of the shield directive.
"""
base_url = SHIELDS_IO / "pypi"
project = self.options.pop("project", self.env.config.github_repository)
self.options["target"] = f"https://pypi.org/project/{project}"
info = {
'v': self.options.pop("version", False),
"py-versions": self.options.pop("py-versions", False),
"implementation": self.options.pop("implementations", False),
"wheel": self.options.pop("wheel", False),
'l': self.options.pop("license", False),
"downloads": self.options.pop("downloads", False),
}
n_info_options: int = len([k for k, v in info.items() if v])
if n_info_options > 1:
raise ValueError("Only one information option is allowed for the 'pypi-badge' directive.")
elif n_info_options == 0:
raise ValueError("An information option is required for the 'pypi-badge' directive.")
for option in {'v', "implementation", "wheel", 'l'}:
if info[option]:
self.arguments = [base_url / option / project]
break
if info["py-versions"]:
self.arguments = [str(base_url / "pyversions" / f"{project}?logo=python&logoColor=white")]
elif info["downloads"]:
if info["downloads"] in {"week", "dw"}:
self.arguments = [base_url / "dw" / project]
elif info["downloads"] in {"month", "dm"}:
self.arguments = [base_url / "dm" / project]
elif info["downloads"] in {"day", "dd"}:
self.arguments = [base_url / "dd" / project]
else:
raise ValueError("Unknown time period for the PyPI download statistics.")
self.options["target"] = f"https://pypistats.org/packages/{project.lower()}"
return super().run()
[docs]class GitHubShield(GitHubBackedShield):
"""
Shield to show information about a GitHub repository.
"""
option_spec: OptionSpec = {
"username": str, # Defaults to "github_username" if undefined
"repository": str, # Defaults to "github_repository" if undefined
"branch": str,
"contributors": flag, # Show the number of contributors.
"commits-since": str, # Show the number of commits since the given tag.
"last-commit": flag, # Show the date of the last commit.
"top-language": flag, # Show the top language and %
"license": flag,
**shield_default_option_spec,
}
def run(self) -> List[nodes.Node]:
"""
Process the content of the shield directive.
"""
base_url = "https://img.shields.io/github"
username, repository = self.get_repo_details()
branch = self.options.pop("branch", "master")
info = {
"contributors": self.options.pop("contributors", False),
"commits-since": self.options.pop("commits-since", False),
"last-commit": self.options.pop("last-commit", False),
"top-language": self.options.pop("top-language", False),
"license": self.options.pop("license", False),
}
n_info_options: int = len([k for k, v in info.items() if v])
if n_info_options > 1:
raise ValueError("Only one information option is allowed for the 'github-badge' directive.")
elif n_info_options == 0:
raise ValueError("An information option is required for the 'github-badge' directive.")
if info["contributors"]:
self.arguments = [f"{base_url}/contributors/{username}/{repository}"]
self.options["target"] = f"https://github.com/{username}/{repository}/graphs/contributors"
elif info["commits-since"]:
self.arguments = [f"{base_url}/commits-since/{username}/{repository}/{info['commits-since']}/{branch}"]
self.options["target"] = f"https://github.com/{username}/{repository}/pulse"
elif info["last-commit"]:
self.arguments = [f"{base_url}/last-commit/{username}/{repository}/{branch}"]
self.options["target"] = f"https://github.com/{username}/{repository}/commit/{branch}"
elif info["top-language"]:
self.arguments = [f"{base_url}/languages/top/{username}/{repository}"]
elif info["license"]:
self.arguments = [f"{base_url}/license/{username}/{repository}"]
self.options["target"] = f"https://github.com/{username}/{repository}/blob/master/LICENSE"
return super().run()
[docs]class MaintainedShield(Shield):
"""
Shield to indicate whether the project is maintained.
"""
required_arguments = 1 # The year
option_spec = dict(shield_default_option_spec)
def run(self) -> List[nodes.Node]:
"""
Process the content of the shield directive.
"""
self.arguments = [f"https://img.shields.io/maintenance/yes/{self.arguments[0]}"]
return super().run()
[docs]class PreCommitShield(Shield):
"""
Shield to indicate that the project uses `pre-commit <https://pre-commit.com/>`_.
"""
required_arguments = 0
option_spec = dict(shield_default_option_spec)
def run(self) -> List[nodes.Node]:
"""
Process the content of the shield directive.
"""
self.arguments = [
"https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white"
]
self.options["target"] = "https://github.com/pre-commit/pre-commit"
return super().run()
RESULTS_PRE_COMMIT_CI = URL("https://results.pre-commit.ci")
[docs]class PreCommitCIShield(GitHubBackedShield):
"""
Shield to show the `pre-commit.ci <https://pre-commit.ci/>`_ status.
.. versionadded:: 1.7.0
"""
option_spec: OptionSpec = {
"username": str, # Defaults to "github_username" if undefined
"repository": str, # Defaults to "github_repository" if undefined
"branch": str,
**shield_default_option_spec,
}
def run(self) -> List[nodes.Node]:
"""
Process the content of the shield directive.
"""
username, repository = self.get_repo_details()
branch = self.options.pop("branch", "master")
url = RESULTS_PRE_COMMIT_CI / "badge" / "github" / username / repository / f"{branch}.svg"
self.arguments = [str(url)]
self.options["target"] = str(RESULTS_PRE_COMMIT_CI / "latest" / "github" / username / repository / branch)
return super().run()
[docs]def copy_asset_files(app: Sphinx, exception: Optional[Exception] = None) -> None:
"""
Copy additional stylesheets into the HTML build directory.
.. versionadded:: 2.3.1
:param app: The Sphinx application.
:param exception: Any exception which occurred and caused Sphinx to abort.
"""
if exception: # pragma: no cover
return
if app.builder is None or app.builder.format.lower() != "html": # pragma: no cover
return
static_dir = PathPlus(app.outdir) / "_static"
static_dir.maybe_make(parents=True)
dict2css.dump(_css.shields_styles, static_dir / "toolbox-shields.css", minify=True)
[docs]@metadata_add_version
def setup(app: Sphinx) -> SphinxExtMetadata:
"""
Setup :mod:`sphinx_toolbox.shields`.
:param app: The Sphinx application.
"""
app.setup_extension("sphinx_toolbox.github")
app.setup_extension("sphinx_toolbox._css")
# Shields/badges
app.add_directive("rtfd-shield", RTFDShield)
app.add_directive("actions-shield", GitHubActionsShield)
app.add_directive("requires-io-shield", RequiresIOShield)
app.add_directive("coveralls-shield", CoverallsShield)
app.add_directive("codefactor-shield", CodefactorShield)
app.add_directive("pypi-shield", PyPIShield)
app.add_directive("github-shield", GitHubShield)
app.add_directive("maintained-shield", MaintainedShield)
app.add_directive("pre-commit-shield", PreCommitShield)
app.add_directive("pre-commit-ci-shield", PreCommitCIShield)
return {"parallel_read_safe": True}