Source code for click_extra.command_doc

# 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.
r"""Extract a Click command into a structured document and render it.

{func}`extract_command_doc` walks a command (and, through
{func}`iter_command_contexts`, its whole tree) into a {class}`CommandDoc`: one
extraction carrying the man-pages(7) sections documented in {doc}`/man-page`
(NAME, SYNOPSIS, DESCRIPTION, OPTIONS, COMMANDS, ENVIRONMENT, FILES and EXIT
STATUS). The model then renders to any of the {class}`HelpFormat` backends:
roff ({meth}`CommandDoc.to_roff`), Markdown ({meth}`CommandDoc.to_markdown`)
and JSON ({meth}`CommandDoc.to_dict` / {meth}`CommandDoc.to_json`), with the
Carapace completion spec delegated to {mod}`click_extra.carapace`.

The roff backend 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;
- mirroring Cloup option groups as `.SS` subsections of OPTIONS (ungrouped
  options fall under an `Other options` heading), matching the help screen;
- 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 json
import logging
import os
import re
import shutil
import subprocess
from dataclasses import dataclass
from enum import Enum
from gettext import gettext as _
from importlib import metadata
from pathlib import Path

import click
from cloup import OptionGroupMixin

from . import context
from ._utils import generator_tag
from .accessibility import echo_via_pager
from .config import ConfigOption
from .envvar import param_envvar_ids
from .parameters import (
    ExtraOption,
    full_short_help,
    iter_params_for_display,
    iter_subcommands,
    make_resilient_context,
    option_value_kind,
    param_spellings,
    resolve_param_help,
    search_params,
    split_option_groups,
)
from .types import EnumChoice
from .version import build_moment, resolve_author, resolve_distribution

TYPE_CHECKING = False
if TYPE_CHECKING:
    from collections.abc import Iterator, Sequence
    from typing import Any

    from click import Command, Context, Parameter


INLINE_LITERAL_RE = re.compile(r"``([^`]+?)``")
"""Match a reST inline literal (`"`...`"`) in a docstring.

Click stores docstrings verbatim, so any reST markup the author used to
render code-like tokens in HTML docs leaks into `Command.help` /
`Command.short_help`. The roff and HTML man-page paths translate these
matches into the bold/literal markers their renderers understand; the
Sphinx index directive translates them into `nodes.literal`.
"""


[docs] def iter_inline_literals(text: str) -> Iterator[tuple[str, bool]]: """Walk `text` and yield `(segment, is_literal)` pairs. Split on {data}`INLINE_LITERAL_RE` so the consumer can apply different rendering to the literal segments (bold for roff, a `literal` node for docutils) without re-parsing the regex. """ pos = 0 for match in INLINE_LITERAL_RE.finditer(text): if match.start() > pos: yield text[pos : match.start()], False yield match.group(1), True pos = match.end() if pos < len(text): yield text[pos:], False
CLICK_EXTRA_URL = "https://github.com/kdeldycke/click-extra" """Click Extra's home page, stamped into the provenance comment of every generated man page so a reader of the raw roff knows where it came from.""" 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: r"""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 _neutralize_leading_control(text: str) -> str: r"""Prefix a zero-width `\&` when `text` starts with a roff control character (`.` or `'`) so it is not mistaken for a macro request. """ if text[:1] in (".", "'"): return "\\&" + text return text 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 _render_inline(text: str) -> str: r"""Render one line of Click help prose to a roff body line. Translates each reST inline literal (`"`...`"`) to a bold span (`\fB...\fR`); escapes plain prose with {func}`_roff_escape`; neutralizes a leading control character (`.` or `'`) so the result is safe to emit between any other roff macros. """ parts: list[str] = [] for segment, is_literal in iter_inline_literals(text): parts.append(_bold(segment) if is_literal else _roff_escape(segment)) return _neutralize_leading_control("".join(parts)) def _emit_help(text: str) -> list[str]: r"""Render Click help/description prose to roff body lines (no section macro). Click marks a no-rewrap region with a `\b` (`\x08`) control character: everything after the marker within the same paragraph is rendered verbatim. Each paragraph is therefore split into a filled prefix and a preformatted suffix, with `.nf` / `.fi` wrapping only the suffix. Paragraphs without a marker collapse to a single filled line, separated from the previous one by `.PP`. """ text = inspect.cleandoc(text).strip() if not text: return [] out: list[str] = [] for index, paragraph in enumerate(re.split(r"\n\s*\n", text)): if not paragraph.strip(): continue if index > 0: out.append(".PP") pre, marker, post = paragraph.partition("\x08") pre = pre.strip() if pre: out.append(_render_inline(" ".join(pre.split()))) if marker: # `\b` may sit on its own line: strip the surrounding # newlines so the .nf block is compact, but keep internal # line breaks so the no-fill region looks as written. post = post.strip("\n") if post: out.append(".nf") out.extend(_render_inline(line) for line in post.splitlines()) out.append(".fi") return out # --- examples ---------------------------------------------------------------
[docs] def normalize_examples( examples: Sequence[Sequence[str]] | None, ) -> tuple[tuple[str, str], ...]: """Validate and freeze a command's `examples` into `(description, command)` pairs. Accepts any sequence of two-item sequences, so a list of tuples and a list of lists (what a configuration file or a JSON payload would produce) are both fine. `None` and an empty sequence both yield an empty tuple. :raises TypeError: when an entry is not a pair of strings, naming the offending entry. Raised at command construction, so a malformed example surfaces on import rather than on the first `--help` a user runs. """ if not examples: return () normalized: list[tuple[str, str]] = [] for entry in examples: if isinstance(entry, str) or len(tuple(entry)) != 2: raise TypeError( f"Example {entry!r} is not a (description, command) pair.", ) description, command_line = entry if not isinstance(description, str) or not isinstance(command_line, str): raise TypeError( f"Example {entry!r} must hold two strings.", ) normalized.append((description, command_line)) return tuple(normalized)
# --- plain-prose helpers ---------------------------------------------------- def _clean_help(text: str) -> str: r"""Normalize Click help prose for the backends that carry newlines natively. Runs {func}`inspect.cleandoc` and drops Click's `\b` (`\x08`) no-rewrap marker, keeping every line break the marker protected. Markdown and JSON both represent those breaks on their own, so neither needs an equivalent of the roff `.nf` / `.fi` pair {func}`_emit_help` emits: only the control character has to go, or it lands in the output as a stray byte. """ return inspect.cleandoc(text).replace("\x08\n", "").replace("\x08", "").strip() def _markdown_help(text: str) -> list[str]: r"""Render Click help prose as Markdown block lines. Paragraphs are emitted as prose, with one exception: the region Click marks with `\b` keeps its shape inside a fenced code block. That marker exists precisely because the author aligned something by hand (a table, a tree, a sample session), and Markdown would reflow it into a single line otherwise. """ text = inspect.cleandoc(text).strip() if not text: return [] out: list[str] = [] for paragraph in re.split(r"\n\s*\n", text): if not paragraph.strip(): continue if out: out.append("") pre, marker, post = paragraph.partition("\x08") pre = pre.strip() if pre: out.append(" ".join(pre.split())) if marker: post = post.strip("\n") if post: if pre: out.append("") out.append("```text") out.extend(post.splitlines()) out.append("```") return out def _markdown_inline(text: str) -> str: """Collapse help prose to a single Markdown line, for list items and tables. Inline reST literals become Markdown code spans, mirroring what {func}`_render_inline` does for roff. """ cleaned = " ".join(_clean_help(text).split()) return "".join( f"`{segment}`" if is_literal else segment for segment, is_literal in iter_inline_literals(cleaned) ) # --- structured man page ----------------------------------------------------
[docs] @dataclass class DocOptionItem: """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` when the option takes no value (boolean flags and counters).""" help: str | None """The option's help text, possibly carrying a `\\b` no-rewrap marker.""" required: bool """Whether the option is mandatory.""" optional_value: bool = False """Whether the option's value is optional (a bare flag is allowed). Rendered as the attached `[=METAVAR]` form instead of a space-separated metavar.""" choices: tuple[str, ...] = () """Every value the option accepts, for an option whose type enumerates them. Recorded whatever the metavar shows, so a JSON consumer never has to parse `[a|b|c]` back out of it. See {attr}`unlisted_choices` for the rendered subset. """ @property def unlisted_choices(self) -> tuple[str, ...]: """The choices the metavar does not already spell out. A `Choice` renders as `[a|b|c]` by default, putting every value on screen already, and repeating them below the help would say the same thing twice. An option overriding that with a short placeholder (`--table-format FORMAT`) hides them instead, and this is what the man page and the Markdown render add back. """ if not self.choices: return () metavar = self.metavar or "" if all(choice in metavar for choice in self.choices): return () return self.choices @property def attached_value(self) -> str: """The `[=METAVAR]` form of an optional value. An optional value renders attached and bracketed (`--color[=auto|always|never]`), the man convention for a flag usable bare. The metavar's own outer brackets, if any, are stripped so a Choice does not double up. """ inner = self.metavar or "" if inner.startswith("[") and inner.endswith("]"): inner = inner[1:-1] return f"[={inner}]"
[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: if self.optional_value: tag += _italic(self.attached_value) else: tag += " " + _italic(self.metavar) lines = [".TP", tag] lines.extend(_emit_help(self.help or "")) if self.unlisted_choices: # Every token goes through the hyphen escape, so a value like # `colon-grid` renders as a copy-pasteable minus sign rather than a # typographic hyphen. values = ", ".join(_roff_escape(c) for c in self.unlisted_choices) lines.append(".br") lines.append(f"[values: {values}]") if self.required: lines.append(".br") lines.append("[required]") return lines
@property def spec(self) -> str: """The option's spelling and value placeholder, as one plain string.""" spec = " / ".join(self.names) if self.metavar: if self.optional_value: spec += self.attached_value else: spec += f" {self.metavar}" return spec
[docs] def to_markdown(self) -> list[str]: r"""Render this option as a Markdown list item. A `\b` no-rewrap region in the help becomes a fenced block indented under the item, rather than being folded into the sentence: the author aligned it on purpose, and a list item can carry a block as well as a paragraph can. """ item = f"- `{self.spec}`" if self.required: item += " *(required)*" pre, _marker, post = (self.help or "").partition("\x08") help_text = _markdown_inline(pre) if help_text: item += f": {help_text}" if self.unlisted_choices: values = ", ".join(f"`{choice}`" for choice in self.unlisted_choices) item += f" Values: {values}." lines = [item] post = inspect.cleandoc(post).strip("\n") if post: lines.append("") lines.append(" ```text") lines.extend(f" {line}" for line in post.splitlines()) lines.append(" ```") lines.append("") return lines
[docs] def to_dict(self) -> dict[str, Any]: """Render this option as a JSON-serializable mapping.""" return { "names": list(self.names), "spec": self.spec, "metavar": self.metavar, "choices": list(self.choices) or None, "help": _clean_help(self.help or "") or None, "required": self.required, "optional_value": self.optional_value, }
[docs] @dataclass class DocSubcommand: """One COMMANDS entry: a subcommand as its parent lists it.""" name: str """The name the subcommand is invoked under.""" short_help: str """Its one-line description, from {func}`~click_extra.parameters.full_short_help`.""" aliases: tuple[str, ...] = () """The other names it answers to, a Cloup feature. Drawn in parentheses after the name, the way the help screen and `--tree` draw them: a reader of a generated page learns the short spelling from the same place they learn the command exists. """
[docs] @dataclass class DocOptionGroup: """A titled cluster of OPTIONS entries, mirroring a Cloup option group. A plain Click command, or a Cloup command with no explicit `@option_group`, yields a single group with `title=None`: it renders as a flat OPTIONS list with no `.SS` subsection heading, identical to a man page that never grouped its options. """ options: tuple[DocOptionItem, ...] """The option entries in this group.""" title: str | None = None """The subsection heading, rendered as a roff `.SS`. `None` for the implicit single group of an ungrouped command (no heading emitted).""" help: str | None = None """Optional group description, rendered as prose under the heading."""
[docs] def to_roff(self) -> list[str]: """Render an optional `.SS` heading, group help, then the options.""" lines: list[str] = [] if self.title: lines.append(".SS " + _quote(self.title)) if self.help: lines.extend(_emit_help(self.help)) for option in self.options: lines.extend(option.to_roff()) return lines
[docs] def to_markdown(self, level: int = 3) -> list[str]: """Render an optional heading, group help, then the options.""" lines: list[str] = [] if self.title: lines.append("#" * level + " " + self.title) lines.append("") if self.help: lines.extend(_markdown_help(self.help)) lines.append("") for option in self.options: lines.extend(option.to_markdown()) return lines
[docs] def to_dict(self) -> dict[str, Any]: """Render this group as a JSON-serializable mapping.""" return { "title": self.title, "help": _clean_help(self.help or "") or None, "options": [option.to_dict() for option in self.options], }
[docs] @dataclass class CommandDoc: """A whole man page in structured form, ready to render to roff. One {class}`CommandDoc` 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.command_doc.extract_command_doc` and serialize with {meth}`to_roff`. """ name: str """Full command path, space-joined (like `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.""" option_groups: tuple[DocOptionGroup, ...] = () """The OPTIONS entries, partitioned into one or more groups. A command without explicit option groups carries a single untitled group.""" subcommands: tuple[DocSubcommand, ...] = () """For groups: the COMMANDS section entries, in listing order.""" 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.""" examples: tuple[tuple[str, str], ...] = () """EXAMPLES entries as `(description, command_line)` pairs. Collected from the command's own `examples` attribute (see {attr}`click_extra.commands.Command.examples`). Empty for a command that declares none, in which case every backend omits the section entirely. """ 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] = [ ( f'.\\" Generated by {generator_tag()} <{CLICK_EXTRA_URL}>. ' "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) # `self.short_help` is the author's docstring or explicit # `short_help`: route it through `_render_inline` so inline # reST literals show up as bold instead of leaking through as # raw backticks rendered as quotes by mandoc. lines.append( f"{name} \\- {_render_inline(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.option_groups: lines.append(".SH OPTIONS") for group in self.option_groups: lines.extend(group.to_roff()) if self.subcommands: lines.append(".SH COMMANDS") for sub in self.subcommands: lines.append(".TP") names = _bold(sub.name) if sub.aliases: names += " ({})".format(", ".join(map(_bold, sub.aliases))) lines.append(names) lines.extend(_emit_help(sub.short_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(".sp") lines.append(".nf") lines.append(_italic(path)) lines.append(".fi") 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.examples: lines.append(".SH EXAMPLES") for index, (description, command_line) in enumerate(self.examples): if index > 0: lines.append(".PP") lines.extend(_emit_help(description)) lines.append(".RS") lines.append(".nf") lines.append(_bold(_roff_escape(command_line))) lines.append(".fi") lines.append(".RE") 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"
[docs] def to_markdown(self) -> str: """Render the whole document as Markdown. Same sections as {meth}`to_roff`, in the same order, minus the roff `.TH` header, whose date, section number and manual name describe a man page rather than the command. The version survives, as a line under the title. """ lines: list[str] = [f"# {self.name}", ""] if self.short_help: lines.extend((_markdown_inline(self.short_help), "")) if self.version: lines.extend((f"Version `{self.version}`.", "")) synopsis = self.name if self.synopsis_pieces: synopsis += " " + " ".join(self.synopsis_pieces) lines.extend(( "## Synopsis", "", "```shell-session", f"$ {synopsis}", "```", "", )) if self.description: lines.extend(("## Description", "")) lines.extend(_markdown_help(self.description)) lines.append("") if self.operands: lines.extend(("## Arguments", "")) for metavar, help_text in self.operands: item = f"- `{metavar}`" rendered = _markdown_inline(help_text) lines.append(f"{item}: {rendered}" if rendered else item) lines.append("") if self.option_groups: lines.extend(("## Options", "")) for group in self.option_groups: lines.extend(group.to_markdown()) lines.append("") if self.subcommands: lines.extend(("## Commands", "")) for sub in self.subcommands: item = f"- `{sub.name}`" if sub.aliases: item += " ({})".format( ", ".join(f"`{alias}`" for alias in sub.aliases) ) rendered = _markdown_inline(sub.short_help) lines.append(f"{item}: {rendered}" if rendered else item) lines.append("") if self.examples: lines.extend(("## Examples", "")) for description, command_line in self.examples: lines.extend(( _markdown_inline(description) + ":", "", "```shell-session", f"$ {command_line}", "```", "", )) if self.environment: lines.extend(("## Environment variables", "")) for var_name, help_text in self.environment: item = f"- `{var_name}`" rendered = _markdown_inline(help_text) lines.append(f"{item}: {rendered}" if rendered else item) lines.append("") if self.files: lines.extend(("## Files", "")) lines.extend(f"- `{path}`" for path in self.files) lines.append("") if self.exit_status: lines.extend(("## Exit status", "")) for code, meaning in self.exit_status: lines.append(f"- `{code}`: {_markdown_inline(meaning)}") lines.append("") if self.authors: lines.extend(("## Authors", "")) lines.extend(_markdown_help(self.authors)) lines.append("") if self.copyright: lines.extend(("## Copyright", "")) lines.extend(_markdown_help(self.copyright)) lines.append("") # Collapse the trailing blank line each section leaves behind. while lines and not lines[-1]: lines.pop() return "\n".join(lines) + "\n"
[docs] def to_dict(self) -> dict[str, Any]: """Render the whole document as a JSON-serializable mapping. Subcommands are listed by name and one-line description only, never recursively: a consumer walking a deep tree asks for the child it cares about instead of paying for the whole tree at once. {func}`render_help` exposes the recursive variant separately, for the consumers that do want everything. """ return { "name": self.name, "short_help": _clean_help(self.short_help) or None, "version": self.version, "synopsis": " ".join((self.name, *self.synopsis_pieces)), "description": _clean_help(self.description) or None, "arguments": [ {"metavar": metavar, "help": _clean_help(help_text) or None} for metavar, help_text in self.operands ], "option_groups": [group.to_dict() for group in self.option_groups], "subcommands": [ { "name": sub.name, "aliases": list(sub.aliases), "short_help": _clean_help(sub.short_help) or None, } for sub in self.subcommands ], "examples": [ {"description": description, "command": command_line} for description, command_line in self.examples ], "environment": [ {"variable": name, "help": _clean_help(help_text) or None} for name, help_text in self.environment ], "files": list(self.files), "exit_status": [ {"code": code, "meaning": _clean_help(meaning)} for code, meaning in self.exit_status ], }
[docs] def to_json(self, indent: int | None = 2) -> str: """Serialize {meth}`to_dict` to a JSON string.""" return json.dumps(self.to_dict(), indent=indent) + "\n"
# --- extraction ------------------------------------------------------------- def _resolve_date() -> str: """The man page date, as `YYYY-MM-DD`. Pinned by `SOURCE_DATE_EPOCH` in a reproducible build, see {func}`~click_extra.version.build_moment`. """ return build_moment().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`. Resolves the distribution from the program name (see {func}`_distribution_names`) and reads its version. Pass `version=` to {func}`render_manpage` to override this. """ name = resolve_distribution(_distribution_names(ctx)) return metadata.version(name) if name else None def _resolve_authors(ctx: Context) -> str | None: """Best-effort AUTHORS lookup from distribution metadata. Resolves the distribution from the program name (see {func}`_distribution_names`) and reads its author(s) through the shared {func}`click_extra.version.resolve_author`, so `--man` and `--version` report the same author string (`Author` / `Maintainer` / email display name, in that order). """ name = resolve_distribution(_distribution_names(ctx)) return resolve_author(metadata.metadata(name)) if name else None def _config_default(config_option: ConfigOption, ctx: Context) -> str: """The portable, home-relative `--config` search pattern. The help screen collapses an inherited format set down to its folder, which a `FILES` section does not want: it exists to name the files the command reads, and roff has the room to print them. See {meth}`~click_extra.config.option.ConfigOption.collapse_default`. """ return config_option.render_default(ctx) 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 `--man` path) is reused as-is. """ config_option = search_params(command.params, ConfigOption) if config_option is None: 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) # FILES is an optional section: any failure resolving the search pattern # (missing context, app-dir lookup errors, …) just drops it silently. except Exception: # noqa: BLE001 return () if not default or default in ("disabled", "None"): return () return (str(default),) def _option_item(param: Parameter, ctx: Context) -> DocOptionItem: """Build a {class}`DocOptionItem` from a single Click option. The metavar follows {func}`~click_extra.parameters.option_value_kind`: a flag or counter takes no value (no metavar), an optional-value option renders the attached `[=METAVAR]` form, and a regular option a space-separated metavar. """ kind = option_value_kind(param) # `Choice` and its subclasses expose the accepted values; every other type # leaves the attribute out, which reads as an empty set here. choices = getattr(param.type, "choices", ()) or () return DocOptionItem( names=param_spellings(param), metavar=None if kind == "flag" else param.make_metavar(ctx=ctx), help=resolve_param_help(param, ctx), required=param.required, optional_value=kind == "optional", choices=tuple(str(choice) for choice in choices), ) def _build_option_groups( command: Command, ctx: Context, option_items: list[tuple[Parameter, DocOptionItem]], ) -> tuple[DocOptionGroup, ...]: """Partition extracted options into man-page OPTIONS subsections. Cloup commands expose explicit option groups: each visible one becomes a titled {class}`DocOptionGroup` (a roff `.SS`), with the ungrouped remainder gathered under Cloup's default-group title, mirroring the `--help` screen. The sections are ordered by {meth}`click_extra.commands.Command.split_option_groups`, so the ungrouped remainder sits between the groups a CLI author declared and the ones Click Extra injects. A command with no option group at all collapses to a single untitled group, rendered as a flat list. Group membership is matched by option identity, not name: Click Extra's `--config` / `--no-config` pair shares the `config` destination name, so a name-keyed lookup would drop one of them. The ungrouped remainder is read off `option_items` rather than off the default group, whose `--help` Click rebuilds on every call and would never match by identity. """ items_by_id = {id(param): item for param, item in option_items} def build(groups: Sequence[Any]) -> list[DocOptionGroup]: built: list[DocOptionGroup] = [] for group in groups: if group.hidden: continue members = tuple( items_by_id[id(opt)] for opt in group.options if id(opt) in items_by_id ) if members: built.append( DocOptionGroup(options=members, title=group.title, help=group.help) ) return built if isinstance(command, OptionGroupMixin) and command.option_groups: own_groups, extra_groups = split_option_groups(command) claimed = {id(opt) for group in command.option_groups for opt in group.options} ungrouped = tuple( item for param, item in option_items if id(param) not in claimed ) before = build(own_groups) after = build(extra_groups) if before or after: middle: list[DocOptionGroup] = [] if ungrouped: title = command.get_default_option_group( ctx, is_the_only_visible_option_group=not before, ).title middle.append(DocOptionGroup(options=ungrouped, title=title)) return (*before, *middle, *after) return (DocOptionGroup(options=ungrouped),) if ungrouped else () items = tuple(item for _, item in option_items) return (DocOptionGroup(options=items),) if items else ()
[docs] def extract_command_doc( command: Command, ctx: Context, *, version: str | None = None, date: str | None = None, manual: str | None = None, authors: str | None = None, copyright: str | None = None, ) -> CommandDoc: """Build a {class}`CommandDoc` from a Click command and its context. The context must have been created for `command` (for example via {meth}`click.Command.make_context` with `resilient_parsing=True`), so that auto-generated environment-variable prefixes resolve correctly. """ operands: list[tuple[str, str]] = [] environment: list[tuple[str, str]] = [] seen_envvars: set[str] = set() option_items: list[tuple[Parameter, DocOptionItem]] = [] for param in iter_params_for_display(command, ctx): if getattr(param, "hidden", False): continue if isinstance(param, click.Argument): operands.append(( param.make_metavar(ctx=ctx), resolve_param_help(param, ctx) or "", )) continue option_items.append((param, _option_item(param, ctx))) for var in param_envvar_ids(param, ctx): if var in seen_envvars: continue seen_envvars.add(var) environment.append((var, resolve_param_help(param, ctx) or "")) subcommands: list[DocSubcommand] = [ DocSubcommand( name=name, short_help=full_short_help(sub), aliases=tuple(getattr(sub, "aliases", None) or ()), ) for name, sub in iter_subcommands(command, ctx) ] return CommandDoc( name=ctx.command_path, short_help=full_short_help(command), synopsis_pieces=tuple(command.collect_usage_pieces(ctx)), description=command.help or "", operands=tuple(operands), option_groups=_build_option_groups(command, ctx, option_items), subcommands=tuple(subcommands), environment=tuple(environment), examples=normalize_examples(getattr(command, "examples", None)), files=_resolve_files(command, ctx), version=version if version is not None else _resolve_version(ctx), date=date if date is not None else _resolve_date(), manual=manual, authors=authors if authors is not None else _resolve_authors(ctx), copyright=copyright, )
[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 or "") if not _path else (command.name or "") ctx = make_resilient_context(command, info_name, parent=_parent) path = _path + (info_name,) yield path, command, ctx for sub_name, sub in iter_subcommands(command, ctx): 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 (like 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.command_doc.extract_command_doc`. """ if ctx is None: ctx = make_resilient_context(command, prog_name or command.name) return extract_command_doc(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 (like `weather-forecast.1`). """ pages: dict[str, str] = {} for path, cmd, ctx in iter_command_contexts(command, prog_name): page = extract_command_doc(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] def install_manpages( command: Command, prog_name: str | None = None, **overrides: str | None, ) -> list[Path]: """Write the command tree's man pages where `man` can find them. Targets `$XDG_DATA_HOME/man/man1` when that variable is set, else {data}`MAN_INSTALL_DIR`. Returns the written paths. The environment is read here rather than at import time, so a caller that sets `XDG_DATA_HOME` for one invocation (a test, a packaging script staging into a build root) is honored. This mirrors {func}`~click_extra.carapace.install_carapace_spec`, whose spec directory resolves the same way. """ xdg = os.environ.get("XDG_DATA_HOME") target = Path(xdg).expanduser() / "man" / "man1" if xdg else MAN_INSTALL_DIR return write_manpages(command, target, prog_name, **overrides)
[docs] class HelpFormat(str, Enum): """The formats {func}`render_help` renders. The value is the spelling `--help-format` takes. Members are ordered alphabetically, which is also the order `--help-format` advertises them in. Adding a format is a member here, a description in {data}`~click_extra.command_doc.HELP_FORMATS` and a branch in {func}`render_help`: no new flag, no wider help screen. See {doc}`/man-page` for what each one is good for. A `str` subclass, so a member compares, hashes and sorts as its value, and a plain string keeps working wherever a format is expected. """ CARAPACE = "carapace" JSON = "json" JSON_FULL = "json-full" MAN = "man" MARKDOWN = "markdown" MARKDOWN_FULL = "markdown-full" def __str__(self) -> str: return self.value
HELP_FORMATS: dict[HelpFormat, str] = { HelpFormat.CARAPACE: ( "Carapace completion spec (YAML). Doubles as a command-and-flag tree, " "and is the shape `carapace` itself consumes. Needs the `yaml` extra." ), HelpFormat.JSON: ( "This command as a JSON object: usage, description, arguments, options " "grouped as the help screen groups them, environment variables, files, " "exit codes, and its direct subcommands by name." ), HelpFormat.JSON_FULL: ( "Every command of the tree as JSON, under a `commands` array, each entry " "in the `json` shape." ), HelpFormat.MAN: ( "This command as a man page: the roff source a packager installs, which " "`--man` typesets for reading." ), HelpFormat.MARKDOWN: "This command as a Markdown document, one section per topic.", HelpFormat.MARKDOWN_FULL: ( "Every command of the tree as one Markdown document, in tree order." ), } """Each {class}`HelpFormat`, mapped to its one-line description. ```{note} The distinction the plain and `-full` variants draw is progressive disclosure. A plain render describes one command and names its children, so a reader (a tool or an agent, typically) descends one level at a time instead of pulling a whole tree into a context window to answer a question about one leaf. The `-full` variants exist for the opposite job: generating documentation, or diffing a CLI's whole surface between two releases. ``` """ INSTALLABLE_FORMATS: frozenset[HelpFormat] = frozenset({ HelpFormat.CARAPACE, HelpFormat.MAN, }) """The formats with a canonical place on disk their consumer reads them from. A man page under a `man` directory, a Carapace spec under Carapace's. These are the two renderings that are *installed* rather than read, which is what lets `click-extra wrap` offer them a destination (`--output-dir`, `--install`) and refuse one to the others. A JSON or Markdown document has no such place: nothing goes looking for it, so stdout and a shell redirection are the whole story. """
[docs] def render_help( command: Command, help_format: HelpFormat | str, prog_name: str | None = None, ctx: Context | None = None, **overrides: str | None, ) -> str: """Render *command* in one of the {class}`HelpFormat` formats. Reuses `ctx` when given (like the live invocation context), otherwise builds a throwaway one with `resilient_parsing=True`, exactly like {func}`render_manpage`. Keyword overrides are passed through to {func}`extract_command_doc`, and ignored by the `carapace` format, which carries no version or authorship of its own. :raises ValueError: on an unknown format, listing the known ones. """ try: help_format = HelpFormat(help_format) except ValueError: known = ", ".join(map(str, HelpFormat)) msg = f"Unknown help format {help_format!r}. Pick one of: {known}." raise ValueError(msg) from None if help_format is HelpFormat.CARAPACE: # Imported here rather than at module level: click_extra.carapace reaches # click_extra.commands, which imports this module for ManOption. from .carapace import dump_carapace_spec # A spec is keyed on the binary name a shell completes, never on the # invocation a synopsis line prints. `prog_name` carries the latter for # the document formats (`click-extra wrap` hands it a whole script path), # so narrow it to the last word here. A spec named after a path binds to # nothing, and does so silently. return dump_carapace_spec( command, prog_name=command.name or (prog_name.split()[-1] if prog_name else None), ) if help_format in (HelpFormat.JSON_FULL, HelpFormat.MARKDOWN_FULL): pages = [ extract_command_doc(cmd, sub_ctx, **overrides) for _path, cmd, sub_ctx in iter_command_contexts(command, prog_name) ] if help_format is HelpFormat.JSON_FULL: return ( json.dumps({"commands": [page.to_dict() for page in pages]}, indent=2) + "\n" ) return "\n".join(page.to_markdown() for page in pages) if ctx is None: ctx = make_resilient_context(command, prog_name or command.name) page = extract_command_doc(command, ctx, **overrides) if help_format is HelpFormat.JSON: return page.to_json() if help_format is HelpFormat.MARKDOWN: return page.to_markdown() return page.to_roff()
MAN_FORMATTERS: tuple[tuple[str, ...], ...] = ( ("groff", "-man", "-Tutf8", "-rLL={width}n", "-P-c"), ("mandoc", "-Tutf8", "-Owidth={width}"), ) """Commands able to typeset roff into readable terminal text, best first. Each entry is an argv template read on stdin, with `{width}` filled from the terminal. `groff` is the GNU implementation found nearly everywhere a man page is; `mandoc` covers the BSDs and Alpine, which ship it instead. ```{note} `-P-c` hands `-c` down to `grotty`, groff's terminal driver, pinning the emphasis it produces to the character-backspace pairs {data}`OVERSTRIKE_RE` matches and {func}`read_manpage` strips under `--accessible`. Left to its own default a `grotty` recent enough writes SGR escape sequences instead, which that regular expression cannot see: the manual then reaches a screen reader with its emphasis intact, and loses it altogether once the output is not a terminal and the codes are stripped as color. `mandoc` needs no counterpart: it overstrikes already. ``` ```{note} The `man` binary is deliberately not in this list, even though it is the tool being imitated. Reading roff from stdin is where the implementations diverge: GNU `man` takes `-l -`, while the BSD one wants a real file path. Driving the typesetter directly sidesteps a portability problem that buys nothing, since paging is handled here anyway. ``` """ MAN_INSTALL_DIR: Path = Path("~/.local/share/man/man1").expanduser() """Where `--install` writes man pages: the user's own section-1 directory. The default of the [XDG base directory spec](https://specifications.freedesktop.org/basedir-spec/latest/), which {func}`install_manpages` overrides from `XDG_DATA_HOME` when that is set. Some systems do not carry this path in their `MANPATH`, in which case the pages land correctly but `man` has to be told where to look. """
[docs] def format_manpage(roff: str, width: int | None = None) -> str | None: """Typeset *roff* into readable terminal text, or `None` if nothing can. Tries each entry of {data}`MAN_FORMATTERS` in turn and returns the output of the first that succeeds. Returns `None` when none of them is installed, which the caller is expected to degrade on rather than fail: a CLI that cannot find a typesetter is a CLI running somewhere that never had man pages to begin with (Windows, a slim container), and that is no reason for `--man` to error. :param roff: the man page source, as {meth}`CommandDoc.to_roff` renders it. :param width: line length in columns. Defaults to the terminal's own, so the result matches what `man` would have produced in the same window. """ if width is None: width = shutil.get_terminal_size().columns for template in MAN_FORMATTERS: if not shutil.which(template[0]): continue argv = [arg.format(width=width) for arg in template] try: process = subprocess.run( argv, input=roff, capture_output=True, text=True, encoding="UTF-8", check=False, ) except OSError: continue if process.returncode == 0 and process.stdout.strip(): return process.stdout return None
OVERSTRIKE_RE = re.compile(r".\x08") """Match the character-backspace pairs a roff typesetter emits for emphasis. A bold `N` is written `N\\x08N` and an underlined one `_\\x08N`, a convention inherited from line printers that a pager still renders as bold and underline today. Dropping the pair's first half leaves the plain character. """
[docs] def read_manpage(command: Command, ctx: Context | None = None) -> None: r"""Typeset a command's manual and send it to the pager. The reading counterpart of `--help-format man`, which emits the roff source a packager installs. Falls back to printing that source, with a warning naming what to install, when no typesetter is available: something on screen beats an error, and the source still carries every word of the manual. Under `--accessible` the emphasis is stripped and the pager bypassed ({func}`~click_extra.accessibility.echo_via_pager` streams instead). Both matter to the same reader: a pager is a cursor-driven takeover, and overstrike is worse than the ANSI codes accessible mode already removes, since a screen reader voices `N\x08NA\x08AM\x08ME\x08E` rather than skipping it. """ roff = render_manpage(command, ctx=ctx) typeset = format_manpage(roff) if typeset is None: logging.getLogger("click_extra").warning( "No man page typesetter found (tried %s): printing the roff source " "instead. Install one to read the manual, or ask for the source on " "purpose with --help-format man.", ", ".join(template[0] for template in MAN_FORMATTERS), ) click.echo(roff) return active_ctx = click.get_current_context(silent=True) if active_ctx is not None and context.get(active_ctx, context.ACCESSIBLE, False): typeset = OVERSTRIKE_RE.sub("", typeset) echo_via_pager(typeset)
[docs] class ManOption(ExtraOption): """A pre-configured `--man` flag that typesets the command's manual, pages it, and exits. Eager and value-less, like {class}`~click_extra.parameters.ShowParamsOption`. Part of the default option set injected by {func}`~click_extra.commands.default_params`, so every `@command` and `@group` exposes it. Use {func}`@man_option <click_extra.decorators.man_option>` to add it to a plain Click CLI. ```{note} The flag is named `--man`, not `--show-man` or `--man-page`. In the POSIX, GNU and BSD traditions a program does not emit its own man page through a flag: the page is a separate file read with `man <prog>`, either hand-written (BSD `mdoc`) or generated at build time from `--help` output (GNU `help2man`). Click Extra already covers that build-time path with {func}`~click_extra.command_doc.write_manpages`, its `help2man` equivalent. The one ecosystem that exposes a *runtime* flag is Perl's `Pod::Usage`, whose convention is `--help` for the brief usage and bare `--man` for the full manual. `--man` also lines up with the neighbouring `--help` and `--version` informational flags, which use bare nouns with no `show-` prefix. `--show-man` and `--man-page` have no precedent outside Click Extra. ``` ```{note} That Perl convention is about *reading* a manual, and this flag used to print roff source instead, which nobody reads: it was a build artifact wearing a reader's name. It now typesets the page and sends it to the pager, the way `man` itself does, so the flag does what its tradition says. The source did not go away, it moved to where a build step looks for it: `--help-format man`, beside every other artifact this module renders. The two are one question apart. Do you want to read the manual, or to ship it? ``` """ def __init__( self, param_decls: Sequence[str] | None = None, *, is_flag: bool = True, expose_value: bool = False, is_eager: bool = True, help: str = _("Read the command's manual page and exit."), **kwargs: Any, ) -> None: if not param_decls: param_decls = ("--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: """Typeset the invoked command's manual, page it, then exit.""" if not value or ctx.resilient_parsing: return read_manpage(ctx.command, ctx=ctx) ctx.exit()
[docs] class HelpFormatOption(ExtraOption): """A pre-configured `--help-format` option printing the command in one of the {class}`HelpFormat` formats and exiting. Eager and value-taking, unlike its `--man` neighbour. That bare flag typesets the same page and sends it to the pager, where `--help-format man` prints its roff source. It stays a flag of its own because a runtime manual flag has its own tradition (see {class}`ManOption`). ```{note} One option carrying a format, rather than one flag per format. A CLI's option list is the most expensive real estate in its help screen, and every reader pays for it whether or not they will ever export anything: a family of `--help-json`, `--help-markdown` and `--help-carapace` flags would widen the label column of every screen, forever, one line per format anyone ever adds. Here a new format costs a {class}`HelpFormat` member and nothing on screen. ``` ```{note} The rendered output is deliberately colorless whatever `--color` says. Every format here is meant to be piped into something (a file, a parser, a model), and ANSI escapes in a JSON string or a Markdown fence are noise to all of them. `--help` remains the colorized human view. ``` """ def __init__( self, param_decls: Sequence[str] | None = None, *, expose_value: bool = False, is_eager: bool = True, help: str = _("Render the command in the given format and exit."), **kwargs: Any, ) -> None: if not param_decls: param_decls = ("--help-format",) kwargs.setdefault("callback", self.print_help_format) kwargs.setdefault("type", EnumChoice(HelpFormat)) super().__init__( param_decls, expose_value=expose_value, is_eager=is_eager, help=help, **kwargs, )
[docs] def print_help_format( self, ctx: Context, param: Parameter, value: HelpFormat | None, ) -> None: """Render the invoked command in the requested format, then exit.""" if not value or ctx.resilient_parsing: return click.echo(render_help(ctx.command, value, ctx=ctx), color=False) ctx.exit()