# Copyright Kevin Deldycke <kevin@deldycke.com> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
"""Helpers and utilities for Sphinx.
```{note}
The MkDocs counterpart lives in {mod}`click_extra.mkdocs`, which achieves the same
ANSI color rendering by patching `pymdownx.highlight`'s formatter classes.
```
```{todo}
Split the documentation stack into a standalone `extra-docs` project, should it
keep growing away from the CLI framework hosting it. It would take this package,
{mod}`click_extra.pygments`, {mod}`click_extra.mkdocs`, the
{mod}`click_extra.markdown` package with its MyST converters and marker-region
primitives, and the ones repomatic keeps in its own `docs/docs_update.py`. The
open question is the `click:source` and `click:run` directives: they invoke a
Click CLI, so either they stay here, or `extra-docs` depends on click-extra.
```
"""
from __future__ import annotations
from .._utils import missing_extra_message
try:
import sphinx # noqa: F401
except ImportError as err:
raise ImportError(missing_extra_message("sphinx", subject="This module")) from err
from sphinx.highlighting import PygmentsBridge
from sphinx.util import logging
from .. import __version__
from ..pygments import AnsiHtmlFormatter
from . import (
changelog,
crosshair,
doctrees,
fail_on_warnings,
linkcheck,
manpages,
matrix,
mermaid,
tables,
tabs,
todos,
viewcode,
)
from .click import cleanup_runner, register_config
from .click_reference import ClickDomain
from .python import (
PythonDomain,
cleanup_python_runner,
rewrite_python_mirror_regions,
)
TYPE_CHECKING = False
if TYPE_CHECKING:
from sphinx.application import Sphinx
from sphinx.config import Config
from sphinx.util.typing import ExtensionMetadata
logger = logging.getLogger(__name__)
EXEC_DIRECTIVES_OPT_IN = "click_extra_enable_exec_directives"
"""Name of the `conf.py` config flag that gates every code-execution directive.
Default is `False`. A project that adds `click_extra.sphinx` to its
`extensions` list gets the ANSI Pygments formatter unconditionally, but
does *not* gain access to either the `click:*` or the
`python:*` directive families until the maintainer opts in explicitly.
Both families `exec` user-supplied Python at build time with full
Sphinx-process privileges: gating them behind a single explicit flag keeps
a transitive import or a doc-only pull request from silently expanding
the build's attack surface.
"""
def _register_exec_directives(app: Sphinx, config: Config) -> None:
"""Register the `click:*` and `python:*` directives if opted in.
Connected to the `config-inited` event so the user's `conf.py`
value is merged before this runs. Without the opt-in, neither
{class}`~click_extra.sphinx.click_reference.ClickDomain` nor
{class}`~click_extra.sphinx.python.PythonDomain` is registered:
referencing any of their directives in a document raises an
"Unknown directive type" warning, as if the extension were
not installed.
```{danger}
Both directive families execute arbitrary Python at build time
with the full privileges of the Sphinx process: filesystem,
network, environment variables, secrets. Auto-enabling them on
every project that imports `click_extra.sphinx` (transitively
or otherwise) would silently expand the attack surface of every
consumer. See `docs/sphinx.md` for the full trust boundary.
```
"""
if not getattr(config, EXEC_DIRECTIVES_OPT_IN, False):
logger.info(
"click_extra.sphinx: click:* and python:* directives are "
"disabled. Set %s = True in conf.py to enable build-time "
"code execution. See docs/sphinx.md for security implications.",
EXEC_DIRECTIVES_OPT_IN,
)
return
app.add_domain(ClickDomain)
app.connect("doctree-read", cleanup_runner)
app.add_domain(PythonDomain)
app.connect("doctree-read", cleanup_python_runner)
# Refresh `python:render :mirror:` regions in memory before the document
# is parsed, so builds always render fresh output even when the committed
# region is stale (the disk copy is refreshed offline by the click-extra
# refresh-directives command). Priority 100 (below the default 500) runs
# it ahead of any other source-read transformer so generated content
# participates in later transforms.
app.connect("source-read", rewrite_python_mirror_regions, priority=100)
[docs]
def setup(app: Sphinx) -> ExtensionMetadata:
"""Register extensions to Sphinx.
Always-on features (no execution surface):
- The ANSI-capable HTML formatter for Pygments (replaces
`sphinx.highlighting.PygmentsBridge` with one that renders ANSI
colors in code blocks).
- The `matrix` directive, which renders a package's compatibility grid
(``{matrix} python`` or ``{matrix} <distribution>``) from its git tag
history. It runs a canned generator rather than user-supplied Python, so
it carries no execution surface and needs no opt-in. See
{mod}`click_extra.sphinx.matrix`.
- Deduplication of the `todolist` page, which `sphinx.ext.todo` fills
with one entry per *rendering* of a `{todo}` directive rather than one
per directive. Inert on a project that enables neither the extension
nor a `todolist`, and switched off with
{data}`click_extra.sphinx.todos.DEDUPE_TODOS_CONFIG`. See
{mod}`click_extra.sphinx.todos`.
- A {data}`click_extra.sphinx.todos.UNRENDERED_TODO_WARNING` warning for
each `{todo}` in a docstring that no page renders, which no `todolist`
can list. Inert on a project that does not enable `sphinx.ext.todo`, and
silenced through `suppress_warnings`. See
{mod}`click_extra.sphinx.todos`.
- A one-line first column for every table whose top-left cell names both
axes, like the `matrix` tables: a narrow screen scrolls the table
instead of wrapping its labels. See {mod}`click_extra.sphinx.tables`.
- A crosshair on the same tables, which highlights the row and the column
under the pointer, and makes a cell holding a single link clickable over
its whole area. See {mod}`click_extra.sphinx.crosshair`.
- A vertical layout for a sphinx-design `tab-set` marked with
`:class: tabs-vertical`: its labels stack down a left rail. Deprecated
for the `:orientation: vertical` option of sphinx-design `0.8.0`, with a
warning on each tab-set that carries the class. Inert without
sphinx-design. See {mod}`click_extra.sphinx.tabs`.
- Class diagrams of `autoclasstree` that let the mouse wheel scroll the
page, where the d3 zoom of sphinxcontrib-mermaid would catch it. Inert
without sphinxcontrib-mermaid, or with `mermaid_d3_zoom` off. See
{mod}`click_extra.sphinx.mermaid`.
- The `linkcheck` requests to github.com signed with the token of
`GITHUB_TOKEN`, release downloads excepted. Inert until the variable is
set. See {mod}`click_extra.sphinx.linkcheck`.
- No `sphinx.ext.viewcode` page for a module that no documented object
links to, so a parallel read publishes the pages of a serial one. Inert
without `sphinx.ext.viewcode`. See {mod}`click_extra.sphinx.viewcode`.
- Every node of a page pointed at the page's own document before Sphinx
pickles it, which keeps the build environment out of the doctree of a
page with an ``{eval-rst}`` block. See {mod}`click_extra.sphinx.doctrees`.
- Changelog releases anchored on their version, like `#v1-4-2`, in place
of a positional `#idN`. Inert until a project sets
{data}`click_extra.sphinx.changelog.RELEASE_ANCHORS_CONFIG`. See
{mod}`click_extra.sphinx.changelog`.
- The man-page hook, which writes roff man pages for the CLIs a project
lists in {data}`click_extra.sphinx.manpages.MANPAGES_CONFIG_KEY` on every
HTML build. Inert until a project lists one. See
{mod}`click_extra.sphinx.manpages`.
- A failed build on the warning types a project lists in
{data}`click_extra.sphinx.fail_on_warnings.FAIL_ON_WARNINGS_CONFIG`,
where Sphinx can only fail on every warning at once. Inert until a
project lists one. See {mod}`click_extra.sphinx.fail_on_warnings`.
Opt-in features (gated behind `click_extra_enable_exec_directives`):
- `click:source` / `click:run` to define and execute Click CLIs
at build time.
- `click:tree` / `click:config` to render the command reference and the
configuration reference of a Click CLI at build time.
- `python:source` / `python:run` to execute arbitrary Python at
build time and render its source or captured `stdout`.
- `python:render` / `python:render-myst` / `python:render-rst`
to execute arbitrary Python and parse the captured `stdout` as
live document content.
All directives in the opt-in group execute user-supplied Python with
the same privileges as the Sphinx process. They are therefore
disabled by default. Set `click_extra_enable_exec_directives = True`
in `conf.py` to register them.
```{caution}
This function replaces the HTML formatter of
`sphinx.highlighting.PygmentsBridge` with
{class}`~click_extra.pygments.AnsiHtmlFormatter`, for every Sphinx app of
the process, so that code blocks render ANSI colors.
```
"""
# Set Sphinx's default HTML formatter to an ANSI capable one.
PygmentsBridge.html_formatter = AnsiHtmlFormatter
# Declare the single opt-in flag covering both directive families.
# The `config-inited` callback below registers the domains only if
# the project's conf.py opts in. Default is `False`: build-time
# arbitrary Python execution is off unless explicitly turned on.
app.add_config_value(EXEC_DIRECTIVES_OPT_IN, False, "env", types=[bool])
# The conf.py values the click:* directives read (see click.py).
register_config(app)
app.connect("config-inited", _register_exec_directives)
# Wire the man-page emit hook (see manpages.py). No-op until a project
# declares one or more entries in `click_extra_manpages`.
manpages.setup(app)
# Register the always-on `matrix` compatibility-matrix directive (see
# matrix.py). Unlike the `click:*` / `python:*` families, it runs a canned
# generator against the documented project's git history rather than
# user-supplied Python, so it needs no exec opt-in.
matrix.setup(app)
# Collapse the duplicate `todolist` entries autodoc's repeated docstring
# renderings produce, and warn about the docstring todos no page renders
# (see todos.py). Inert unless the project enables `sphinx.ext.todo`.
todos.setup(app)
# Keep the first column of a corner-labeled table on one line (see
# tables.py). Every HTML page links the stylesheet, but only a table with a
# corner label carries the class it styles.
tables.setup(app)
# Highlight the row and the column under the pointer in the same tables
# (see crosshair.py).
crosshair.setup(app)
# Lay out a tab-set marked `tabs-vertical` in two columns, and warn that the
# class is deprecated (see tabs.py).
tabs.setup(app)
# Keep the d3 zoom of sphinxcontrib-mermaid off the `autoclasstree` diagrams
# (see mermaid.py). Inert without it, or with `mermaid_d3_zoom` off.
mermaid.setup(app)
# Sign the `linkcheck` requests to github.com with `GITHUB_TOKEN` (see
# linkcheck.py). Inert until the variable is set.
linkcheck.setup(app)
# Drop the viewcode pages that no documented object links to (see
# viewcode.py). Inert without `sphinx.ext.viewcode`.
viewcode.setup(app)
# Point the nodes an `{eval-rst}` block moves into a page at that page, so
# its pickled doctree leaves out the build environment (see doctrees.py).
doctrees.setup(app)
# Anchor each changelog release on its version (see changelog.py). Inert
# until `click_extra_release_anchors` is set.
changelog.setup(app)
# Fail the build on the warning types a project lists (see
# fail_on_warnings.py). Inert until `click_extra_fail_on_warnings` names one.
fail_on_warnings.setup(app)
return {
"version": __version__,
"parallel_read_safe": True,
"parallel_write_safe": True,
}