Source code for click_extra.sphinx.manpages

# 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.
"""Sphinx integration to render roff man pages alongside the HTML build.

A project that adds `click_extra.sphinx` to its `extensions` list and
declares one or more entries in `click_extra_manpages` gets its Click
command tree(s) emitted as `.1` files into `<outdir>/<output_dir>/` on
every HTML build, with no project-local helper script. Pages mirror what
{func}`click_extra.command_doc.write_manpages` produces from a CLI invocation,
so the docs site, the release pipeline, and downstream packagers all share
one generator.

When `mandoc` or `groff` is available on `PATH`, each `.1` file is
also rendered to a browser-viewable `.html` sibling. Browsers download
raw `.1` files rather than display them, so the HTML pass is what makes
Sphinx's `:manpage:` role useful when `manpages_url` points at this
hook's output.

The hook only fires for HTML-class builders (`html`, `dirhtml`,
`singlehtml`). Non-HTML builders (`linkcheck`, `man`, `epub`,
`coverage`, etc.) skip it: they typically have different output
semantics, and writing roff into a `linkcheck` `output/` directory
serves no purpose.

Configuration shape::

    click_extra_manpages = [
        {
            "script": "meta_package_manager.cli:mpm",  # required
            "prog_name": "mpm",  # optional, see below
            "output_dir": "man",  # optional, defaults to "man"
            "render_html": True,  # optional, see below
        },
    ]

* `script` is resolved by {func}`click_extra.cli_wrapper.resolve_target_command`
  exactly as it would be from the `click-extra man` CLI: a
  `console_scripts` entry-point name, a `module:function` path, a
  `.py` file, or a plain module name.
* `prog_name` is the basename used for both the man-page `.TH` header
  and the generated filenames. When omitted, it falls back to the resolved
  Click command's own `name` attribute (`mpm` for the
  `meta_package_manager.cli:mpm` target), matching the default the
  `click-extra man --output-dir` CLI uses.
* `output_dir` is a relative path under `app.outdir`. It is created
  on demand and reused across builds.
* `render_html` toggles the HTML sibling pass. Defaults to `True`.
  When no renderer is on `PATH`, the build still produces the `.1`
  files and logs a single info-level notice; set `render_html` to
  `False` to suppress that notice.

An empty (or absent) `click_extra_manpages` list disables the feature,
which is the default for every project pulling in the extension.

Cross-referencing the generated pages from prose
================================================

To make ``:manpage:`myprog(1)``` resolve to the HTML sibling this hook
emits, set Sphinx's `manpages_url` to the same `output_dir`::

    manpages_url = "man/{page}.{section}.html"

Sphinx's role provides ``{page}``, ``{section}`` and ``{path}``
placeholders; the file layout produced here is ``{page}.{section}`` plus
the optional `.html` extension, so the template above matches every
file regardless of how deep the subcommand tree goes.
"""

from __future__ import annotations

import posixpath
import shutil
import subprocess
from pathlib import Path

from docutils import nodes
from sphinx.directives import SphinxDirective
from sphinx.errors import NoUri
from sphinx.util import logging
from sphinx.util.osutil import relative_uri

from ..cli_wrapper import resolve_target_command
from ..command_doc import (
    iter_command_contexts,
    iter_inline_literals,
    write_manpages,
)
from ..parameters import full_short_help

TYPE_CHECKING = False
if TYPE_CHECKING:
    from click import Command
    from sphinx.application import Sphinx


logger = logging.getLogger(__name__)


MANPAGES_CONFIG_KEY = "click_extra_manpages"
"""Name of the `conf.py` config flag holding the man-page emit list."""


DEFAULT_OUTPUT_DIR = "man"
"""Subdirectory under `app.outdir` where `.1` files land when the
caller omits the `output_dir` entry. Picked to match the URL fragment
projects typically publish their man pages under
(like `https://example.com/<project>/man/<cli>.1`)."""


HTML_BUILDER_NAMES = frozenset({"html", "dirhtml", "singlehtml"})
"""Builder names that get the man-page emit hook.

Restricted to HTML-family builders because they are the ones whose output
directory becomes the published docs site. Other builders (`linkcheck`,
`man`, `epub`, `coverage`) have different output semantics, and
writing roff into their build trees would either be redundant or
confusing."""


HTML_RENDERERS: tuple[tuple[str, tuple[str, ...]], ...] = (
    ("mandoc", ("-Thtml",)),
    ("groff", ("-Thtml", "-mandoc")),
)
"""External roff β†’ HTML renderers, tried in order.

`mandoc` is preferred: its HTML output ships semantic `id` anchors on
every section and option (`#NAME`, `#SYNOPSIS`, `#config`…), which
makes deep-linking from prose work. `groff -Thtml -mandoc` is the GNU
fallback. If neither is on `PATH`, the HTML pass is skipped and only
the `.1` files are emitted.
"""


_RENDERER_TIMEOUT_S = 30
"""Per-file ceiling on the renderer invocation. mandoc finishes a typical
CLI page in under 100 ms; the timeout exists to bound damage from a
pathological page or a hung external process."""


_ROFF_PROBE = ".TH TEST 1\n.SH NAME\ntest \\- probe\n"
"""Minimal roff source used to verify a renderer actually produces output.

Some environments install the renderer binary but not the HTML support
package (like `groff` without `groff-html` on Debian/Ubuntu ARM
runners). A bare `shutil.which` check would accept such a broken install
and later produce zero HTML files. The probe catches this early so the
caller can fall through to the next candidate.
"""


def _find_renderer() -> tuple[str, tuple[str, ...]] | None:
    """Locate the first available roff β†’ HTML renderer on `PATH`.

    Returns `(executable, extra_argv)` so the caller can append a file
    path and run it. Returns `None` when no candidate is available or
    when the candidate is installed but cannot produce HTML output (like
    `groff` present but `groff-html` absent).

    A quick probe with a trivial roff snippet guards against the latter
    case: if the renderer exits non-zero or produces empty output, it is
    treated as absent.
    """
    for name, extra in HTML_RENDERERS:
        path = shutil.which(name)
        if not path:
            continue
        # Verify the renderer actually works rather than just existing.
        try:
            result = subprocess.run(
                [path, *extra],
                input=_ROFF_PROBE,
                capture_output=True,
                text=True,
                check=True,
                timeout=_RENDERER_TIMEOUT_S,
            )
        except (subprocess.SubprocessError, OSError):
            continue
        if result.stdout.strip():
            return path, extra
    return None


def _render_html(renderer: tuple[str, tuple[str, ...]], roff_path: Path) -> str | None:
    """Run `renderer` on `roff_path` and return its captured `stdout`.

    Returns `None` if the subprocess fails for any reason. Failure is
    logged at warning level but does not abort the build: a broken HTML
    pass on one page must not lose the rest of the docs.
    """
    executable, extra = renderer
    try:
        result = subprocess.run(
            [executable, *extra, str(roff_path)],
            capture_output=True,
            text=True,
            check=True,
            timeout=_RENDERER_TIMEOUT_S,
        )
    except (subprocess.SubprocessError, OSError) as exc:
        logger.warning(
            "click_extra.sphinx: %s failed on %s: %s",
            Path(executable).name,
            roff_path.name,
            exc,
        )
        return None
    return result.stdout


def _emit_manpages(app: Sphinx) -> None:
    """Walk `click_extra_manpages` and write each tree under `app.outdir`.

    No-ops for non-HTML builders and for an empty config list. Errors
    resolving a single entry are logged but do not abort the build, so
    one misconfigured entry cannot derail an otherwise-good docs deploy.

    For every emitted `.1` file, also write a browser-viewable `.html`
    sibling when the entry opts in (default) and a renderer is on
    `PATH`. The renderer is looked up once per build, so a project with
    several entries pays the `shutil.which` cost a single time.
    """
    if app.builder.name not in HTML_BUILDER_NAMES:
        return

    entries = getattr(app.config, MANPAGES_CONFIG_KEY, None) or ()
    if not entries:
        return

    renderer = _find_renderer()
    renderer_notice_logged = False

    for index, entry in enumerate(entries):
        script = entry.get("script")
        if not script:
            logger.warning(
                "click_extra.sphinx: %s[%d] is missing the required "
                "'script' key; skipping entry.",
                MANPAGES_CONFIG_KEY,
                index,
            )
            continue

        output_dir = entry.get("output_dir") or DEFAULT_OUTPUT_DIR
        target = Path(app.outdir) / output_dir

        # The hook fires once per HTML build, so an unimportable script must
        # not abort the whole docs build: log and skip it instead.
        try:
            cmd, _ = resolve_target_command(script)
        except Exception as exc:  # noqa: BLE001
            logger.warning(
                "click_extra.sphinx: cannot resolve %s[%d] script %r: %s",
                MANPAGES_CONFIG_KEY,
                index,
                script,
                exc,
            )
            continue

        prog_name = entry.get("prog_name") or cmd.name or script
        written = write_manpages(cmd, target, prog_name=prog_name)
        logger.info(
            "click_extra.sphinx: wrote %d man page(s) for %r into %s",
            len(written),
            prog_name,
            target,
        )

        render_html = entry.get("render_html", True)
        if not render_html:
            continue
        if renderer is None:
            # Tell the user once per build why no HTML was produced.
            # Suppressed when the user opts out explicitly via
            # `render_html: False` because they did not ask for it.
            if not renderer_notice_logged:
                logger.info(
                    "click_extra.sphinx: no roff renderer found on PATH "
                    "(tried %s); skipping HTML man-page rendering. Set "
                    "render_html=False to suppress this notice.",
                    ", ".join(name for name, _ in HTML_RENDERERS),
                )
                renderer_notice_logged = True
            continue

        html_written = 0
        for roff_path in written:
            html = _render_html(renderer, roff_path)
            if html is None:
                continue
            roff_path.with_suffix(roff_path.suffix + ".html").write_text(
                html, encoding="utf-8"
            )
            html_written += 1
        logger.info(
            "click_extra.sphinx: rendered %d HTML man page(s) for %r with %s",
            html_written,
            prog_name,
            Path(renderer[0]).name,
        )


MANPAGE_LIST_DIRECTIVE = "click-extra-manpages"
"""Name of the directive that renders an auto-generated index of every
man page declared in {data}`MANPAGES_CONFIG_KEY`. The hyphenated form
mirrors the `click_extra_manpages` config key it surfaces."""


_MANPAGE_URI_ATTRIBUTE = "click_extra_manpage_uri"
"""Node attribute carrying a man page's URI relative to the build root.

The `href` a reader needs depends on where the builder publishes the page
hosting the directive: `html` writes a nested document to `<docname>.html`,
`dirhtml` writes `<docname>/index.html` one directory deeper, and `singlehtml`
folds every document into one page at the root. So the directive stamps the
build-root-relative URI here and `_rebase_manpage_links` rewrites it into a
page-relative one at write time.

Resolving it in the directive instead would freeze one builder's layout into
the pickled doctree, and a build reusing that cache never re-runs the
directive: an `html` build followed by a `dirhtml` build sharing a doctree
directory would publish the `html` links."""


[docs] class ManpageListDirective(SphinxDirective): """Render a bullet list with one link per emitted man page. The directive walks every entry in {data}`MANPAGES_CONFIG_KEY` and, for each, calls {func}`~click_extra.command_doc.iter_command_contexts` to discover the (sub)command tree. Each list item links to the corresponding `.1.html` file written by the emit hook. Link targets are stamped as build-root-relative URIs and rebased against the enclosing page by `_rebase_manpage_links`, so the list works whether it appears at the docs root or in a nested page. The directive takes no arguments and no content: it surfaces whatever the config declares at the time the doc is built. """ has_content = False required_arguments = 0 optional_arguments = 0
[docs] def run(self) -> list[nodes.Node]: entries = getattr(self.config, MANPAGES_CONFIG_KEY, None) or () result: list[nodes.Node] = [] for index, entry in enumerate(entries): script = entry.get("script") if not script: continue # Same resilience contract as `_emit_manpages`: a single # broken entry must not break the doc page that hosts the # directive. Log and move on. try: cmd, _ = resolve_target_command(script) except Exception as exc: # noqa: BLE001 logger.warning( "click_extra.sphinx: %s directive cannot resolve " "%s[%d] script %r: %s", MANPAGE_LIST_DIRECTIVE, MANPAGES_CONFIG_KEY, index, script, exc, ) continue output_dir = entry.get("output_dir") or DEFAULT_OUTPUT_DIR prog_name = entry.get("prog_name") or cmd.name or script result.append(self._render_entry(cmd, prog_name, output_dir)) return result
def _render_entry( self, cmd: Command, prog_name: str, output_dir: str, ) -> nodes.bullet_list: """Build the bullet list of links for one `click_extra_manpages` entry's command tree. """ list_node = nodes.bullet_list() for path, sub_cmd, _ctx in iter_command_contexts(cmd, prog_name): name = "-".join(path) url = posixpath.join(output_dir, f"{name}.1.html") # Seed `refuri` with the build-root-relative URI so the node is # renderable on its own, then let `_rebase_manpage_links` rewrite # it for the page the builder is about to write. ref = nodes.reference("", "", refuri=url) ref[_MANPAGE_URI_ATTRIBUTE] = url ref += nodes.literal(text=f"{name}(1)") para = nodes.paragraph() para += ref short_help = full_short_help(sub_cmd) if short_help: para += nodes.Text(" β€” ") # Translate any reST inline literal (`X`) in the help # text to a `nodes.literal` so it renders with the # docs code font instead of leaking through as raw # backticks. for segment, is_literal in iter_inline_literals(short_help): if is_literal: para += nodes.literal(text=segment) else: para += nodes.Text(segment) item = nodes.list_item() item += para list_node += item return list_node
def _rebase_manpage_links(app: Sphinx, doctree: nodes.document, docname: str) -> None: """Rewrite every man-page link relative to the page being written. Nodes stamped with `_MANPAGE_URI_ATTRIBUTE` carry a build-root-relative URI; this rebases each one against the URI the builder assigns to `docname`. The rebasing is delegated to Sphinx's own `sphinx.util.osutil.relative_uri`, which reads each layout the way the builder means it: a trailing slash for `dirhtml`, a bare fragment for `singlehtml`. Runs on every written page rather than once at parse time, so links stay correct when a doctree cache is shared between builders. """ try: page_uri = app.builder.get_target_uri(docname) except NoUri: # `latex` and `texinfo` publish no URI for a document outside their # own tree. Leave the seeded build-root-relative URI in place. return for node in doctree.findall(nodes.reference): target = node.get(_MANPAGE_URI_ATTRIBUTE) if target: node["refuri"] = relative_uri(page_uri, target)
[docs] def setup(app: Sphinx) -> None: """Register the man-page hooks and the index directive on `app`. Called from {func}`click_extra.sphinx.setup` so projects only need to list `"click_extra.sphinx"` in their `extensions`. The hooks are a no-op when `click_extra_manpages` is unset or empty, and the directive renders nothing in that case. """ app.add_config_value(MANPAGES_CONFIG_KEY, default=[], rebuild="env", types=[list]) app.connect("builder-inited", _emit_manpages) app.connect("doctree-resolved", _rebase_manpage_links) app.add_directive(MANPAGE_LIST_DIRECTIVE, ManpageListDirective)