# 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.
"""Generate roff/troff man pages from Click commands.
Produces one man page per command, mirroring the man-pages(7) section
structure documented in :doc:`/man-page`: NAME, SYNOPSIS, DESCRIPTION,
OPTIONS, COMMANDS, ENVIRONMENT, FILES and EXIT STATUS.
This is Click Extra's answer to the unmaintained `click-man
<https://github.com/click-contrib/click-man>`_ package. It improves on it by:
- working on a command *object* via :meth:`click.Command.make_context`, so it
needs no ``console_scripts`` entry point;
- discovering subcommands dynamically through
:meth:`click.Group.list_commands` / :meth:`click.Group.get_command` with a
live context;
- honoring Click's ``\\b`` no-rewrap marker (rendered as roff ``.nf`` / ``.fi``);
- rendering boolean flags (``--foo`` / ``--no-foo``) and skipping hidden
commands and options;
- emitting ENVIRONMENT (from auto-generated env vars), FILES (from the
``--config`` search pattern) and EXIT STATUS sections that click-man never
grew.
Font selection follows the man typographic convention encoded by
:data:`click_extra.theme.LITERAL_STYLES` / :data:`~click_extra.theme.REPLACEABLE_STYLES`:
literal tokens (command and option names) render bold (``\\fB``), replaceable
tokens (metavars, operands) render italic (``\\fI``).
"""
from __future__ import annotations
import inspect
import os
import re
from dataclasses import dataclass
from datetime import datetime, timezone
from importlib import metadata
from pathlib import Path
import click
from .config import ConfigOption
from .envvar import param_envvar_ids
from .parameters import ExtraOption, search_params
TYPE_CHECKING = False
if TYPE_CHECKING:
from collections.abc import Iterator
from click import Command, Context, Parameter
MAN_SECTION = "1"
"""Default man page section. Section 1 is for executable programs and shell
commands, which is what a Click CLI is."""
DEFAULT_EXIT_STATUS: tuple[tuple[str, str], ...] = (
("0", "Success."),
(
"1",
"A runtime error, or an aborted prompt (Ctrl-C, a declined confirmation).",
),
(
"2",
"A usage error: unknown option, invalid value, missing operand, or an "
"unparsable configuration file.",
),
)
"""Conventional exit codes shared by every Click Extra CLI.
Mirrors the EXIT STATUS table in :doc:`/man-page`. Click returns ``2`` for
usage errors (``UsageError``), ``1`` for aborts, and ``0`` on success.
"""
# --- roff helpers -----------------------------------------------------------
def _roff_escape(text: str) -> str:
"""Escape inline text for roff.
Backslashes become ``\\e`` first (so escapes added afterwards survive), then
literal hyphens become ``\\-`` so they render as copy-pasteable minus signs
rather than typographic hyphens (important for option names like
``--config``).
"""
return text.replace("\\", "\\e").replace("-", "\\-")
def _roff_line(text: str) -> str:
"""Escape a whole output line, neutralizing a leading control character.
A line starting with ``.`` or ``'`` is a roff control request; prefix such
lines with the zero-width ``\\&`` so literal text is not mistaken for a
macro.
"""
escaped = _roff_escape(text)
if escaped[:1] in (".", "'"):
escaped = "\\&" + escaped
return escaped
def _bold(text: str) -> str:
"""Wrap text in the roff bold font escape."""
return f"\\fB{_roff_escape(text)}\\fR"
def _italic(text: str) -> str:
"""Wrap text in the roff italic font escape."""
return f"\\fI{_roff_escape(text)}\\fR"
def _quote(text: str) -> str:
"""Quote a ``.TH`` header field, dropping any embedded double quotes."""
return '"{}"'.format(text.replace('"', ""))
def _emit_help(text: str) -> list[str]:
"""Render Click help/description prose to roff body lines (no section macro).
Click marks a block that must not be rewrapped by prefixing it with a
``\\b`` (``\\x08``) control character. Any text carrying that marker is
emitted between ``.nf`` / ``.fi`` so its line breaks survive; ordinary
prose is collapsed and filled, with ``.PP`` between paragraphs.
"""
text = inspect.cleandoc(text).strip()
if not text:
return []
if "\x08" in text:
lines = [".nf"]
lines.extend(_roff_line(line) for line in text.replace("\x08", "").splitlines())
lines.append(".fi")
return lines
out: list[str] = []
for index, paragraph in enumerate(re.split(r"\n\s*\n", text)):
collapsed = " ".join(paragraph.split())
if not collapsed:
continue
if index > 0:
out.append(".PP")
out.append(_roff_line(collapsed))
return out
# --- structured man page ----------------------------------------------------
[docs]
@dataclass
class ManOptionItem:
"""A single OPTIONS entry, extracted from a Click option."""
names: tuple[str, ...]
"""All literal spellings: primary ``opts`` followed by ``secondary_opts``
(so ``--foo`` / ``--no-foo`` boolean flags render both)."""
metavar: str | None
"""The rendered metavar, or ``None`` for boolean flags (which take no value)."""
is_choice: bool
"""Whether the option's type is a :class:`click.Choice`."""
help: str | None
"""The option's help text, possibly carrying a ``\\b`` no-rewrap marker."""
envvars: tuple[str, ...]
"""Environment variables read by the option, auto-generated one included."""
required: bool
"""Whether the option is mandatory."""
[docs]
def to_roff(self) -> list[str]:
"""Render this option as a roff tagged paragraph (``.TP``)."""
tag = " / ".join(_bold(name) for name in self.names)
if self.metavar:
tag += " " + _italic(self.metavar)
lines = [".TP", tag]
lines.extend(_emit_help(self.help or ""))
if self.required:
lines.append(".br")
lines.append("[required]")
return lines
[docs]
@dataclass
class ManPage:
"""A whole man page in structured form, ready to render to roff.
One :class:`ManPage` maps to one command (or subcommand). Its fields are
the man-pages(7) sections, in the order :doc:`/man-page` documents them.
Build it with :func:`~click_extra.man_page.extract_manpage` and serialize with :meth:`to_roff`.
"""
name: str
"""Full command path, space-joined (e.g. ``weather forecast``)."""
short_help: str = ""
"""One-line description for the NAME section."""
section: str = MAN_SECTION
"""Man section number."""
synopsis_pieces: tuple[str, ...] = ()
"""Usage metavars after the command name (``[OPTIONS]``, ``CITY``, ...)."""
description: str = ""
"""The command's full help text / docstring for the DESCRIPTION section."""
operands: tuple[tuple[str, str], ...] = ()
"""Positional arguments as ``(metavar, help)`` pairs."""
options: tuple[ManOptionItem, ...] = ()
"""The OPTIONS entries."""
subcommands: tuple[tuple[str, str], ...] = ()
"""For groups: ``(name, short_help)`` pairs for the COMMANDS section."""
environment: tuple[tuple[str, str], ...] = ()
"""ENVIRONMENT entries as ``(variable_name, help)`` pairs."""
files: tuple[str, ...] = ()
"""FILES entries (configuration search patterns)."""
exit_status: tuple[tuple[str, str], ...] = DEFAULT_EXIT_STATUS
"""EXIT STATUS entries as ``(code, meaning)`` pairs."""
version: str | None = None
"""Version string for the ``.TH`` header."""
date: str = ""
"""Date for the ``.TH`` header (``YYYY-MM-DD``)."""
manual: str | None = None
"""Manual name for the ``.TH`` header (the centered footer title)."""
authors: str | None = None
"""AUTHORS section content, or ``None`` to omit the section."""
copyright: str | None = None
"""COPYRIGHT section content, or ``None`` to omit the section."""
@property
def title(self) -> str:
"""The ``.TH`` page title: the command path, hyphen-joined and upper-cased."""
return self.name.replace(" ", "-").upper()
[docs]
def to_roff(self) -> str:
"""Render the full man page as a roff/troff string."""
lines: list[str] = [
".\\\" Generated by Click Extra. Do not edit by hand.",
" ".join(
(
".TH",
_quote(self.title),
_quote(self.section),
_quote(self.date),
_quote(self.version or ""),
_quote(self.manual or ""),
)
),
]
lines.append(".SH NAME")
name = _roff_escape(self.name)
lines.append(f"{name} \\- {_roff_escape(self.short_help)}" if self.short_help else name)
lines.append(".SH SYNOPSIS")
synopsis = _bold(self.name)
if self.synopsis_pieces:
synopsis += " " + " ".join(_italic(piece) for piece in self.synopsis_pieces)
lines.append(synopsis)
if self.description or self.operands:
lines.append(".SH DESCRIPTION")
lines.extend(_emit_help(self.description))
for metavar, help_text in self.operands:
lines.append(".TP")
lines.append(_italic(metavar))
lines.extend(_emit_help(help_text))
if self.options:
lines.append(".SH OPTIONS")
for option in self.options:
lines.extend(option.to_roff())
if self.subcommands:
lines.append(".SH COMMANDS")
for sub_name, sub_help in self.subcommands:
lines.append(".TP")
lines.append(_bold(sub_name))
lines.extend(_emit_help(sub_help))
if self.environment:
lines.append(".SH ENVIRONMENT")
for var_name, help_text in self.environment:
lines.append(".TP")
lines.append(_bold(var_name))
lines.extend(_emit_help(help_text))
if self.files:
lines.append(".SH FILES")
for index, path in enumerate(self.files):
if index > 0:
lines.append(".br")
lines.append(_italic(path))
if self.exit_status:
lines.append('.SH "EXIT STATUS"')
for code, meaning in self.exit_status:
lines.append(".TP")
lines.append(_bold(code))
lines.extend(_emit_help(meaning))
if self.authors:
lines.append(".SH AUTHORS")
lines.extend(_emit_help(self.authors))
if self.copyright:
lines.append(".SH COPYRIGHT")
lines.extend(_emit_help(self.copyright))
return "\n".join(lines) + "\n"
# --- extraction -------------------------------------------------------------
def _resolve_date() -> str:
"""Resolve the man page date, honoring ``SOURCE_DATE_EPOCH`` for reproducible
builds (https://reproducible-builds.org/specs/source-date-epoch/)."""
epoch = os.environ.get("SOURCE_DATE_EPOCH")
when = (
datetime.fromtimestamp(int(epoch), tz=timezone.utc)
if epoch
else datetime.now(tz=timezone.utc)
)
return when.strftime("%Y-%m-%d")
def _distribution_names(ctx: Context) -> tuple[str, ...]:
"""Candidate distribution names to probe for version and author metadata."""
root = ctx.find_root().info_name or ""
return tuple(dict.fromkeys((root, root.replace("-", "_"), root.replace("_", "-"))))
def _resolve_version(ctx: Context) -> str | None:
"""Best-effort version lookup via :mod:`importlib.metadata`.
Pass ``version=`` to :func:`render_manpage` to override this.
"""
for name in _distribution_names(ctx):
if not name:
continue
try:
return metadata.version(name)
except metadata.PackageNotFoundError:
continue
return None
def _resolve_authors(ctx: Context) -> str | None:
"""Best-effort AUTHORS lookup from distribution metadata."""
for name in _distribution_names(ctx):
if not name:
continue
try:
meta = metadata.metadata(name)
except metadata.PackageNotFoundError:
continue
author = meta.get("Author") or meta.get("Author-email")
if author:
return author
return None
def _config_default(config_option: ConfigOption, ctx: Context) -> str | None:
"""The portable, home-relative ``--config`` search pattern (as shown in help)."""
return config_option.get_help_extra(ctx).get("default")
def _resolve_files(command: Command, ctx: Context) -> tuple[str, ...]:
"""FILES entries from the command's ``--config`` search pattern, if any.
``ConfigOption.default_pattern`` reads :func:`click.get_current_context`, so
the context is entered when none is active (the build-time path); the live
invocation context (the ``--show-man`` path) is reused as-is.
"""
config_option = search_params(command.params, ConfigOption)
if not isinstance(config_option, ConfigOption):
return ()
try:
if click.get_current_context(silent=True) is None:
with ctx:
default = _config_default(config_option, ctx)
else:
default = _config_default(config_option, ctx)
except Exception:
return ()
if not default or default in ("disabled", "None"):
return ()
return (str(default),)
[docs]
def iter_command_contexts(
command: Command,
prog_name: str | None = None,
_parent: Context | None = None,
_path: tuple[str, ...] = (),
) -> Iterator[tuple[tuple[str, ...], Command, Context]]:
"""Walk a command tree, yielding ``(path, command, context)`` for each
visible command.
Subcommands are discovered dynamically (:meth:`click.Group.list_commands` /
:meth:`~click.Group.get_command`), so dynamically-registered commands are
included. Hidden commands are skipped. Each context is built with
``resilient_parsing=True`` to avoid triggering required-argument errors,
prompts, or eager-option side effects.
"""
info_name = (prog_name or command.name) if not _path else (command.name or "")
ctx = command.make_context(
info_name, [], parent=_parent, resilient_parsing=True
)
path = _path + (info_name,)
yield path, command, ctx
if isinstance(command, click.Group):
for sub_name in command.list_commands(ctx):
sub = command.get_command(ctx, sub_name)
if sub is None or getattr(sub, "hidden", False):
continue
yield from iter_command_contexts(sub, _parent=ctx, _path=path)
[docs]
def render_manpage(
command: Command,
prog_name: str | None = None,
ctx: Context | None = None,
**overrides: str | None,
) -> str:
"""Render a single command's man page as a roff string.
Reuses ``ctx`` when given (e.g. the live invocation context), otherwise
builds a throwaway one with ``resilient_parsing=True``. Keyword overrides
(``version``, ``date``, ``manual``, ``authors``, ``copyright``) are passed
through to :func:`~click_extra.man_page.extract_manpage`.
"""
if ctx is None:
ctx = command.make_context(
prog_name or command.name, [], resilient_parsing=True
)
return extract_manpage(command, ctx, **overrides).to_roff()
[docs]
def render_manpages(
command: Command,
prog_name: str | None = None,
**overrides: str | None,
) -> dict[str, str]:
"""Render the whole command tree, one man page per (sub)command.
Returns an ordered mapping of ``{filename: roff}`` where each filename is
the command path joined by hyphens plus the section suffix (e.g.
``weather-forecast.1``).
"""
pages: dict[str, str] = {}
for path, cmd, ctx in iter_command_contexts(command, prog_name):
page = extract_manpage(cmd, ctx, **overrides)
pages["{}.{}".format("-".join(path), page.section)] = page.to_roff()
return pages
[docs]
def write_manpages(
command: Command,
target_dir: str | Path,
prog_name: str | None = None,
**overrides: str | None,
) -> list[Path]:
"""Render the command tree and write each man page into ``target_dir``.
Creates ``target_dir`` if missing. Returns the list of written paths.
"""
target = Path(target_dir)
target.mkdir(parents=True, exist_ok=True)
written: list[Path] = []
for filename, roff in render_manpages(command, prog_name, **overrides).items():
path = target / filename
path.write_text(roff, encoding="utf-8")
written.append(path)
return written
[docs]
class ManOption(ExtraOption):
"""A pre-configured ``--show-man`` flag that prints the command's man page
(roff) to stdout and exits.
Eager and value-less, like :class:`~click_extra.parameters.ShowParamsOption`.
Not part of the default option set: add it explicitly with
:func:`@man_option <click_extra.decorators.man_option>` when you want a CLI
to emit its own man page.
"""
def __init__(
self,
param_decls: tuple[str, ...] | None = None,
is_flag: bool = True,
expose_value: bool = False,
is_eager: bool = True,
help: str = "Show the command's man page (roff) and exit.",
**kwargs,
) -> None:
if not param_decls:
param_decls = ("--show-man",)
kwargs.setdefault("callback", self.print_man)
super().__init__(
param_decls,
is_flag=is_flag,
expose_value=expose_value,
is_eager=is_eager,
help=help,
**kwargs,
)
[docs]
def print_man(self, ctx: Context, param: Parameter, value: bool) -> None:
"""Render and print the invoked command's man page, then exit."""
if not value or ctx.resilient_parsing:
return
click.echo(render_manpage(ctx.command, ctx=ctx))
ctx.exit()