"""RST documentation generation functions for cyclopts apps."""
from typing import TYPE_CHECKING
from cyclopts._markup import extract_text
from cyclopts.docs.base import (
adjust_filters_for_subcommand,
extract_description,
extract_usage,
generate_anchor,
get_app_info,
is_all_builtin_flags,
iterate_commands,
normalize_command_filters,
should_include_command,
should_show_usage,
)
if TYPE_CHECKING:
from cyclopts.core import App
def make_rst_code_block_title(title: str) -> list[str]:
"""Create an RST code block containing the title.
Parameters
----------
title : str
Title text to display in code block.
Returns
-------
list[str]
RST formatted code block lines.
"""
return [
".. code-block:: text",
"",
f" {title}",
]
def make_rst_section_header(title: str, level: int) -> list[str]:
"""Create an RST section header.
Parameters
----------
title : str
Section title.
level : int
Heading level (1-6).
Returns
-------
list[str]
RST formatted section header lines.
"""
markers = {
1: "=",
2: "-",
3: "^",
4: '"',
5: "'",
6: "~",
}
if level < 1:
level = 1
elif level > 6:
level = 6
marker = markers[level]
underline = marker * len(title)
if level == 1:
return [underline, title, underline]
else:
return [title, underline]
def _build_command_map(app: "App", include_hidden: bool = True) -> dict[str, "App"]:
"""Build mapping of command names to App objects.
Parameters
----------
app : App
The app to extract commands from.
include_hidden : bool
Whether to include hidden commands.
Returns
-------
dict[str, App]
Mapping of command names to App instances.
"""
command_map = {}
if app._commands:
for name, subapp in iterate_commands(app, include_hidden):
command_map[name] = subapp
return command_map
def _filter_command_entries(
entries: list,
command_map: dict[str, "App"],
parent_path: list[str],
normalized_filter: set[str] | None,
normalized_exclude: set[str] | None,
) -> list:
"""Filter command entries based on inclusion/exclusion rules.
Parameters
----------
entries : list
Command entries to filter.
command_map : dict[str, App]
Mapping of command names to App objects.
parent_path : list[str]
Parent command path.
normalized_filter : set[str] | None
Normalized filter set.
normalized_exclude : set[str] | None
Normalized exclude set.
Returns
-------
list
Filtered command entries.
"""
filtered_entries = []
for entry in entries:
if entry.names:
cmd_name = entry.names[0]
subapp = command_map.get(cmd_name)
if subapp is None:
# If command not in map and no filters, include it
if normalized_filter is None and normalized_exclude is None:
filtered_entries.append(entry)
else:
# Check if command should be included
if should_include_command(cmd_name, parent_path, normalized_filter, normalized_exclude, subapp):
filtered_entries.append(entry)
return filtered_entries
def _generate_toc(lines: list[str]) -> None:
"""Generate table of contents using RST contents directive.
The `.. contents::` directive automatically generates a TOC from
section headings, which is the idiomatic approach for RST/Sphinx.
"""
lines.append(".. contents:: Table of Contents")
lines.append(" :local:")
lines.append(" :depth: 6")
lines.append("")
def generate_rst_docs(
app: "App",
recursive: bool = True,
include_hidden: bool = False,
heading_level: int = 1,
max_heading_level: int = 6,
command_chain: list[str] | None = None,
generate_toc: bool = True,
flatten_commands: bool = False,
commands_filter: list[str] | None = None,
exclude_commands: list[str] | None = None,
no_root_title: bool = False,
code_block_title: bool = False,
skip_preamble: bool = False,
) -> str:
"""Generate reStructuredText documentation for a CLI application.
Parameters
----------
app : App
The cyclopts App instance to document.
recursive : bool
If True, generate documentation for all subcommands recursively.
Default is True.
include_hidden : bool
If True, include hidden commands/parameters in documentation.
Default is False.
heading_level : int
Starting heading level for the main application title.
Default is 1 (uses '=' markers).
max_heading_level : int
Maximum heading level to use. Headings deeper than this will be capped
at this level. RST uses different underline characters for each level.
Default is 6.
command_chain : list[str]
Internal parameter to track command hierarchy.
Default is None.
generate_toc : bool
If True, generate a table of contents for multi-command apps.
Default is True.
flatten_commands : bool
If True, generate all commands at the same heading level instead of nested.
Default is False.
commands_filter : list[str], optional
If specified, only include commands in this list.
Supports nested command paths like "db.migrate".
Default is None (include all commands).
exclude_commands : list[str], optional
If specified, exclude commands in this list.
Supports nested command paths like "db.migrate".
Default is None (no exclusions).
no_root_title : bool
If True, skip generating the root application title.
Useful when embedding in existing documentation with its own title.
Default is False.
skip_preamble : bool
If True, skip the description and usage sections for the target command
when filtering to a single command via ``commands_filter``.
Useful when the user provides their own section introduction.
Default is False.
Returns
-------
str
The generated RST documentation.
"""
from cyclopts.help.formatters.rst import RstFormatter
lines = []
if command_chain is None:
command_chain = []
app_name, full_command, base_title = get_app_info(app, command_chain)
# Title logic: match markdown behavior for consistency
# - Hierarchical mode: show just command name (last part of chain)
# - Flattened mode: show full command path
# - Root: use base title
if command_chain and not flatten_commands:
# Hierarchical: show just the command name (last part of chain)
title = command_chain[-1]
elif command_chain:
# Flattened: show full command path
title = full_command
else:
# Root app: use base title
title = base_title
# Always generate RST anchor/label with improved namespacing
# RST uses a "cyclopts-" prefix for namespacing
anchor_parts = ["cyclopts"]
if command_chain:
anchor_parts.extend(command_chain)
else:
anchor_parts.append(app_name)
# Use shared anchor generation logic, then add RST-specific slash replacement
anchor_name = generate_anchor(" ".join(anchor_parts)).replace("/", "-")
lines.append(f".. _{anchor_name}:")
lines.append("")
# Determine effective heading level for this command
if no_root_title and not command_chain:
# Skip title entirely for root when no_root_title is True
effective_heading_level = heading_level
elif flatten_commands and command_chain:
# When flattening, all commands use the same heading level
effective_heading_level = heading_level
else:
# Normal hierarchical: increment level for nested commands
effective_heading_level = heading_level + len(command_chain) - 1 if command_chain else heading_level
# Cap at max_heading_level
effective_heading_level = min(effective_heading_level, max_heading_level)
if not (no_root_title and not command_chain):
if code_block_title:
header_lines = make_rst_code_block_title(title)
else:
header_lines = make_rst_section_header(title, effective_heading_level)
lines.extend(header_lines)
lines.append("")
help_format = app.app_stack.resolve("help_format", fallback="restructuredtext")
# Add usage section first if appropriate (skip if skip_preamble is True)
if not skip_preamble and should_show_usage(app):
# Generate usage line - only if we're documenting a specific command
if not (no_root_title and not command_chain):
# Extract usage from app
usage = extract_usage(app)
usage_text = None
if usage:
if isinstance(usage, str):
usage_text = usage
else:
usage_text = extract_text(usage, None, preserve_markup=False)
# Format usage with command chain if this is a subcommand
if command_chain:
# Add command chain to usage
parts = usage_text.split(None, 1)
if len(parts) > 1:
usage_text = f"{' '.join(command_chain)} {parts[1]}"
else:
usage_text = " ".join(command_chain)
if usage_text:
# Use literal block with double colon
lines.append("::")
lines.append("")
# Indent usage text with 4 spaces for literal block
for line in usage_text.split("\n"):
lines.append(f" {line}")
lines.append("")
# Add description (skip if skip_preamble is True)
if not skip_preamble:
description = extract_description(app, help_format)
if description:
# Extract plain text from description
# Preserve markup when help_format matches output format (RST)
preserve = help_format in ("restructuredtext", "rst")
desc_text = extract_text(description, None, preserve_markup=preserve)
if desc_text:
lines.append(desc_text.strip())
lines.append("")
# Generate table of contents at root level only
if generate_toc and not command_chain and app._commands:
_generate_toc(lines)
# Get help panels for the current app
# Use app_stack context - if caller set up parent context, it will be stacked
with app.app_stack([app]):
help_panels_with_groups = app._assemble_help_panels([], help_format)
# Set up command filtering
normalized_commands_filter, normalized_exclude_commands = normalize_command_filters(
commands_filter, exclude_commands
)
parent_path: list[str] = []
# Build a mapping of command names to App objects for filtering
command_map = _build_command_map(app, include_hidden=True)
# Create formatter for help panels
formatter = RstFormatter(heading_level=heading_level + 1, include_hidden=include_hidden)
# Render panels as-is without categorization
for group, panel in help_panels_with_groups:
# Skip hidden panels unless include_hidden is True
if not include_hidden and group and not group.show:
continue
# Skip if no_root_title and we're at root
if no_root_title and not command_chain:
continue
# Render command panels as grouped command lists
if panel.format == "command":
# Filter out built-in flags (--help, --version) from command panels
command_entries = [e for e in panel.entries if not (e.names and is_all_builtin_flags(app, e.names))]
if not command_entries:
continue # Skip empty panel
# Apply command filtering
filtered_entries = _filter_command_entries(
command_entries, command_map, parent_path, normalized_commands_filter, normalized_exclude_commands
)
if not filtered_entries:
continue # Skip if nothing after filtering
# Render group title
if panel.title:
lines.append(f"**{panel.title}:**")
lines.append("")
# Render commands as RST definition list
for entry in filtered_entries:
primary_name = entry.names[0] if entry.names else ""
desc = extract_text(entry.description, None)
lines.append(f"``{primary_name}``")
if desc:
lines.append(f" {desc}")
lines.append("")
# Render parameter panels as-is
elif panel.format == "parameter":
# Render content first to check if there's anything
formatter.reset()
panel_copy = panel.copy(title="")
formatter(None, None, panel_copy)
output = formatter.get_output().strip()
# Only render if there's actual content
if output:
if panel.title:
lines.append(f"**{panel.title}:**")
lines.append("")
lines.append(output)
lines.append("")
if recursive and app._commands:
normalized_commands_filter, normalized_exclude_commands = normalize_command_filters(
commands_filter, exclude_commands
)
parent_path = []
for name, subapp in iterate_commands(app, include_hidden):
if not should_include_command(
name, parent_path, normalized_commands_filter, normalized_exclude_commands, subapp
):
continue
lines.append("")
subcommand_chain = command_chain + [name] if command_chain else [app_name, name]
if flatten_commands:
next_heading_level = heading_level
elif no_root_title and not command_chain:
next_heading_level = heading_level - 1
else:
next_heading_level = heading_level
sub_commands_filter, sub_exclude_commands = adjust_filters_for_subcommand(
name, normalized_commands_filter, normalized_exclude_commands
)
# Determine if this subcommand should skip its preamble
# Skip preamble when: we're at root, skip_preamble is True, and this is the single target command
# OR this is an intermediate command on the path to a nested target
is_single_target = (
not command_chain
and skip_preamble
and commands_filter is not None
and len(commands_filter) == 1
and name == commands_filter[0]
)
is_intermediate_path = (
not command_chain
and skip_preamble
and commands_filter is not None
and len(commands_filter) == 1
and commands_filter[0].startswith(name + ".")
)
# Push subapp onto app_stack - context will stack with recursive call's app_stack([app])
with subapp.app_stack([app, subapp]):
subdocs = generate_rst_docs(
subapp,
recursive=recursive,
include_hidden=include_hidden,
heading_level=next_heading_level,
max_heading_level=max_heading_level,
command_chain=subcommand_chain,
generate_toc=False, # Only generate TOC at root level
flatten_commands=flatten_commands,
commands_filter=sub_commands_filter,
exclude_commands=sub_exclude_commands,
no_root_title=is_intermediate_path, # Skip title for intermediate path commands
code_block_title=code_block_title,
skip_preamble=is_single_target or is_intermediate_path, # Skip preamble for target or intermediate
)
lines.append(subdocs)
# Join and normalize multiple consecutive blank lines to a single blank line
import re
doc = "\n".join(lines)
doc = re.sub(r"\n{3,}", "\n\n", doc)
return doc