"""Documentation generation functions for cyclopts apps.""" from typing import TYPE_CHECKING from cyclopts._markup import extract_text from cyclopts.core import DEFAULT_FORMAT from cyclopts.docs.base import ( adjust_filters_for_subcommand, apply_usage_name, build_command_chain, extract_description, extract_usage, format_usage_line, generate_anchor, get_app_info, is_all_builtin_flags, iterate_commands, normalize_command_filters, should_include_command, should_show_commands_list, should_show_usage, ) if TYPE_CHECKING: from cyclopts.core import App def _collect_commands_for_toc( app: "App", include_hidden: bool = False, prefix: str = "", commands_filter: list[str] | None = None, exclude_commands: list[str] | None = None, parent_path: list[str] | None = None, skip_filtered_command: bool = False, ) -> list[tuple[str, "App"]]: """Recursively collect all commands for table of contents. Returns a list of (display_name, app) tuples. """ commands = [] if parent_path is None: parent_path = [] normalized_commands_filter, normalized_exclude_commands = normalize_command_filters( commands_filter, exclude_commands ) 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 # Skip the command itself if it's the single filtered command with subcommands # (we'll include its children directly instead) skip_this_command = skip_filtered_command and subapp._commands if not skip_this_command: display_name = f"{prefix}{name}" if prefix else name commands.append((display_name, subapp)) # Collect nested commands nested_path = parent_path + [name] # Always include parent in prefix for nested commands (for correct paths) nested_prefix = f"{prefix}{name} " nested = _collect_commands_for_toc( subapp, include_hidden=include_hidden, prefix=nested_prefix, commands_filter=commands_filter, exclude_commands=exclude_commands, parent_path=nested_path, skip_filtered_command=False, # Only skip at the top level ) commands.extend(nested) return commands def _generate_toc_entries(lines: list[str], commands: list[tuple[str, "App"]]) -> None: """Generate TOC entries with proper indentation. Parameters ---------- lines : list[str] List to append TOC entries to. commands : list[tuple[str, "App"]] List of (display_name, app) tuples. """ anchor_counts: dict[str, int] = {} for display_name, _app in commands: depth = display_name.count(" ") - 1 indent = " " * depth cmd_name = display_name.split()[-1] anchor = generate_anchor(display_name) if anchor in anchor_counts: anchor_counts[anchor] += 1 anchor = f"{anchor}_{anchor_counts[anchor]}" else: anchor_counts[anchor] = 0 lines.append(f"{indent}- [`{cmd_name}`](#{anchor})") 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 _append_if_present(lines: list[str], content: str, add_blank: bool = True) -> None: """Append content to lines if present, optionally adding blank line. Parameters ---------- lines : list[str] List to append to. content : str Content to append (only if non-empty). add_blank : bool Whether to add a blank line after content. """ if content: lines.append(content) if add_blank: lines.append("") def _render_description_section(app: "App", help_format: str, lines: list[str]) -> None: """Extract and render app description. Parameters ---------- app : App The app to extract description from. help_format : str Help format (e.g., "markdown", "rich"). lines : list[str] List to append description to. """ description = extract_description(app, help_format) if description: # Preserve markup when help_format matches output format (markdown) preserve = help_format in ("markdown", "md") desc_text = extract_text(description, None, preserve_markup=preserve) if desc_text: lines.append(desc_text.strip()) lines.append("") def _render_usage_section( app: "App", command_chain: list[str], lines: list[str], usage_name: str | None = None, ) -> None: """Render usage console block. Parameters ---------- app : App The app to extract usage from. command_chain : list[str] Command chain for usage line. lines : list[str] List to append usage to. usage_name : str | None Optional replacement for the chain's root in Usage: lines only. """ if should_show_usage(app): usage = extract_usage(app) if usage: lines.append("```console") if isinstance(usage, str): usage_text = usage else: usage_text = extract_text(usage, None, preserve_markup=False) display_chain = apply_usage_name(command_chain, usage_name) usage_line = format_usage_line(usage_text, display_chain) lines.append(usage_line) lines.append("```") lines.append("") def _render_toc( app: "App", app_name: str, include_hidden: bool, commands_filter: list[str] | None, exclude_commands: list[str] | None, skip_filtered_command: bool, lines: list[str], ) -> None: """Generate and render table of contents. Parameters ---------- app : App The app to generate TOC for. app_name : str Application name for TOC prefixes. include_hidden : bool Whether to include hidden commands. commands_filter : list[str] | None Commands to include. exclude_commands : list[str] | None Commands to exclude. skip_filtered_command : bool Whether to skip the single filtered command in TOC. lines : list[str] List to append TOC to. """ # Collect all commands recursively for TOC toc_commands = _collect_commands_for_toc( app, include_hidden=include_hidden, prefix=f"{app_name} " if app_name else "", commands_filter=commands_filter, exclude_commands=exclude_commands, skip_filtered_command=skip_filtered_command, ) if toc_commands: lines.append("## Table of Contents") lines.append("") _generate_toc_entries(lines, toc_commands) lines.append("") def _render_parameter_panel(panel, formatter, lines: list[str]) -> None: """Render a parameter panel as-is. Parameters ---------- panel : HelpPanel The parameter panel to render. formatter : MarkdownFormatter Formatter to use for rendering. lines : list[str] List to append rendered content to. """ # Render panel 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}**:\n") lines.append(output) lines.append("") 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_markdown_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, usage_name: str | None = None, ) -> str: """Generate markdown 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 (single #). max_heading_level : int Maximum heading level to use. Headings deeper than this will be capped at this level. Standard Markdown supports levels 1-6. 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] | None 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] | None 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 the root application title. Used for plugin contexts. 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. usage_name : str | None Optional replacement for the root app name used in ``Usage:`` lines only. Headings and TOC anchors still use ``app.name[0]``. Default is None (use ``app.name[0]`` as before). Returns ------- str The generated markdown documentation. """ from cyclopts.help.formatters.markdown import MarkdownFormatter # Build the main documentation lines = [] if command_chain is None: command_chain = [] is_root = True else: is_root = False # Determine if we should skip the current level's content # When filtering to a single command, skip the root app content skip_current_level = is_root and commands_filter is not None and len(commands_filter) == 1 # Determine the app name and full command path app_name, full_command, base_title = get_app_info(app, command_chain) # Always use full command path for nested commands to avoid anchor collisions # (e.g., "files cp" and "other cp" would both generate #cp without this) if command_chain: # Show full command path (same for both hierarchical and flattened modes) title = f"`{full_command}`" if code_block_title else full_command else: # Root app: use base title title = base_title # Add title for all levels (unless skipping root title or skipping current level entirely) if not skip_current_level and not (no_root_title and is_root): effective_level = min(heading_level, max_heading_level) lines.append(f"{'#' * effective_level} {title}") lines.append("") # Get help format (needed for both current level and recursive docs) help_format = app.app_stack.resolve("help_format", fallback=DEFAULT_FORMAT) # Add usage section first (skip if skipping current level or skip_preamble is True) if not skip_current_level and not skip_preamble: _render_usage_section(app, command_chain, lines, usage_name=usage_name) # Add application description (skip if skipping current level or skip_preamble is True) if not skip_current_level and not skip_preamble: _render_description_section(app, help_format, lines) # Generate table of contents if this is the root level and has commands if generate_toc and not command_chain and app._commands: _render_toc(app, app_name, include_hidden, commands_filter, exclude_commands, skip_current_level, lines) # Get help panels for the current app (skip if skipping current level) # Use app_stack context - if caller set up parent context, it will be stacked if not skip_current_level: with app.app_stack([app]): help_panels_with_groups = app._assemble_help_panels([], help_format) # Set up command filtering (used for command panels only) normalized_commands_filter, normalized_exclude_commands = normalize_command_filters( commands_filter, exclude_commands ) parent_path = [] # Build a mapping of command names to App objects for filtering command_map = _build_command_map(app, include_hidden=True) # Create formatter formatter = MarkdownFormatter( heading_level=heading_level + 1, include_hidden=include_hidden, table_style="list", ) # Iterate through panels in the order provided by _assemble_help_panels (already sorted) for group, panel in help_panels_with_groups: # Skip hidden groups if not include_hidden and group and not group.show: continue if panel.format == "command": # Always filter out built-in flags (--help, --version) from command panels # These are standard CLI flags, not commands, and shouldn't appear here 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 ) # Only render if there are filtered entries if filtered_entries: if panel.title: lines.append(f"**{panel.title}**:\n") # Render command entries with hyperlinks to their sections for entry in filtered_entries: if entry.names: cmd_name = entry.names[0] # Generate anchor for the full command path # Use full_command (not app_name) to include the complete path for nested apps full_cmd_path = f"{full_command} {cmd_name}" anchor = generate_anchor(full_cmd_path) desc_text = ( extract_text(entry.description, None, preserve_markup=True) if entry.description else "" ) if desc_text: lines.append(f"* [`{cmd_name}`](#{anchor}): {desc_text}") else: lines.append(f"* [`{cmd_name}`](#{anchor})") lines.append("") elif panel.format == "parameter": # Handle parameter panels - split into arguments and options if needed _render_parameter_panel(panel, formatter, lines) else: # When skipping current level, still need to set up filter variables for recursive docs normalized_commands_filter, normalized_exclude_commands = normalize_command_filters( commands_filter, exclude_commands ) parent_path = [] # Handle recursive documentation for subcommands if app._commands: # Iterate through registered commands using iterate_commands helper # This automatically resolves CommandSpec instances 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 # Build the command chain for this subcommand sub_command_chain = build_command_chain(command_chain, name, app_name) # Determine heading level for subcommand if flatten_commands: sub_heading_level = heading_level elif no_root_title and not command_chain: # When root title is skipped, subcommands "take over" the root heading level sub_heading_level = heading_level else: sub_heading_level = heading_level + 1 # Check if we should skip this command's title heading # Skip title when: root was skipped (single command filter) AND this is the direct target # OR this is an intermediate command on the path to a nested target # This allows the markdown author's section title to serve as the heading is_single_filter = commands_filter is not None and len(commands_filter) == 1 is_exact_target = is_single_filter and commands_filter is not None and name == commands_filter[0] is_intermediate_path = ( is_single_filter and commands_filter is not None and commands_filter[0].startswith(name + ".") ) skip_this_command_title = skip_current_level and is_exact_target # Also skip intermediate commands entirely when skip_preamble is set skip_intermediate = skip_preamble and skip_current_level and is_intermediate_path # Skip preamble for the exact target when skip_preamble is set (even in recursive calls) skip_target_preamble = skip_preamble and is_exact_target # Generate subcommand title (skip if this is the single filtered command at root level, # or if this is an intermediate command and skip_preamble is set) if not skip_this_command_title and not skip_intermediate: # Always use full command path to avoid anchor collisions display_name = " ".join(sub_command_chain) display_fmt = f"`{display_name}`" if code_block_title else display_name effective_sub_level = min(sub_heading_level, max_heading_level) lines.append(f"{'#' * effective_sub_level} {display_fmt}") lines.append("") # Get subapp help - show description, usage, and panels for included commands # Skip preamble (description + usage) if: # - skip_preamble is True and this is the exact target (even in recursive calls) # - or this is an intermediate command on the path to a nested target skip_this_preamble = skip_target_preamble or skip_intermediate # Include parent app in the stack so default_parameter is properly inherited with subapp.app_stack([app, subapp]): sub_help_format = subapp.app_stack.resolve("help_format", fallback=help_format) # Preserve markup when sub_help_format matches output format (markdown) preserve_sub = sub_help_format in ("markdown", "md") if not skip_this_preamble: # Generate usage first for subcommand _render_usage_section(subapp, sub_command_chain, lines, usage_name=usage_name) _render_description_section(subapp, sub_help_format, lines) # Only show subcommand panels if we're in recursive mode # (Otherwise we just show the basic info about this command) if recursive: # Get help panels for subcommand (already sorted) sub_panels = subapp._assemble_help_panels([], sub_help_format) # Set up command filtering for this subcommand sub_commands_filter_for_panel, sub_exclude_commands_for_panel = adjust_filters_for_subcommand( name, normalized_commands_filter, normalized_exclude_commands ) normalized_sub_filter_panel, normalized_sub_exclude_panel = normalize_command_filters( sub_commands_filter_for_panel, sub_exclude_commands_for_panel ) # Build a map of command names to App objects for filtering sub_command_map = _build_command_map(subapp, include_hidden=True) # Build parent path for nested commands # Use empty path since filter was already adjusted to strip current level's prefix nested_parent_path_for_panel = [] # Create formatter if flatten_commands: panel_heading_level = heading_level + 1 else: panel_heading_level = heading_level + 2 sub_formatter = MarkdownFormatter( heading_level=panel_heading_level, include_hidden=include_hidden, table_style="list" ) # Check if we'll be recursively documenting commands will_recurse = recursive and subapp._commands # Iterate through panels in order for group, panel in sub_panels: # Skip hidden groups if not include_hidden and group and not group.show: continue if panel.format == "command" and should_show_commands_list(subapp): # Always filter out built-in flags (--help, --version) from command panels command_entries_list = [ e for e in panel.entries if not (e.names and is_all_builtin_flags(subapp, e.names)) ] if not command_entries_list: continue # Skip empty panel # Apply command filtering for command panels if will_recurse: # Show simple command list command_entries = [] for entry in command_entries_list: if entry.names: cmd_name = entry.names[0] sub_cmd_app = sub_command_map.get(cmd_name) if sub_cmd_app and not should_include_command( cmd_name, nested_parent_path_for_panel, normalized_sub_filter_panel, normalized_sub_exclude_panel, sub_cmd_app, ): continue desc_text = ( extract_text(entry.description, None, preserve_markup=preserve_sub) if entry.description else "" ) # Generate anchor for the full command path full_cmd_path = " ".join(sub_command_chain + [cmd_name]) anchor = generate_anchor(full_cmd_path) if desc_text: command_entries.append(f"* [`{cmd_name}`](#{anchor}): {desc_text}") else: command_entries.append(f"* [`{cmd_name}`](#{anchor})") if command_entries: if panel.title: lines.append(f"**{panel.title}**:\n") lines.extend(command_entries) lines.append("") else: # Show full command panel filtered_entries = [] for entry in command_entries_list: if entry.names: cmd_name = entry.names[0] sub_cmd_app = sub_command_map.get(cmd_name) if sub_cmd_app and not should_include_command( cmd_name, nested_parent_path_for_panel, normalized_sub_filter_panel, normalized_sub_exclude_panel, sub_cmd_app, ): continue filtered_entries.append(entry) if filtered_entries: if panel.title: lines.append(f"**{panel.title}**:\n") sub_formatter.reset() filtered_panel = panel.__class__( title="", entries=filtered_entries, format=panel.format, description=panel.description, ) sub_formatter(None, None, filtered_panel) output = sub_formatter.get_output().strip() if output: lines.append(output) lines.append("") elif panel.format == "parameter": # Handle parameter panels - split into arguments and options if needed _render_parameter_panel(panel, sub_formatter, lines) # Process nested commands INSIDE the with block so context is preserved if recursive and subapp._commands: sub_commands_filter, sub_exclude_commands = adjust_filters_for_subcommand( name, normalized_commands_filter, normalized_exclude_commands ) normalized_sub_filter, normalized_sub_exclude = normalize_command_filters( sub_commands_filter, sub_exclude_commands ) # Build parent path for nested commands # Use empty path since filter was already adjusted to strip current level's prefix nested_parent_path = [] for nested_name, nested_app in iterate_commands(subapp, include_hidden): if not should_include_command( nested_name, nested_parent_path, normalized_sub_filter, normalized_sub_exclude, nested_app ): continue # Build nested command chain (always use full path for correct usage) nested_command_chain = build_command_chain(sub_command_chain, nested_name, app_name) # Determine heading level for nested commands if flatten_commands: nested_heading_level = heading_level elif skip_this_command_title: # When parent command's title was skipped, promote nested commands to parent's level nested_heading_level = sub_heading_level else: nested_heading_level = sub_heading_level + 1 # Determine commands_filter for the recursive call # Adjust filter to strip current command's prefix for the nested level if normalized_sub_filter: nested_commands_filter, _ = adjust_filters_for_subcommand( nested_name, normalized_sub_filter, normalized_sub_exclude ) else: nested_commands_filter = None # Check if this nested command is the target for skip_preamble purposes # This handles nested paths like "parent.child" where "child" is the target nested_is_target = ( skip_preamble and sub_commands_filter is not None and len(sub_commands_filter) == 1 and nested_name == sub_commands_filter[0] ) # Also check if this is an intermediate on a deeper path nested_is_intermediate = ( skip_preamble and sub_commands_filter is not None and len(sub_commands_filter) == 1 and sub_commands_filter[0].startswith(nested_name + ".") ) # Set up context for nested_app, then recurse # The recursive call's app_stack([app]) will stack on top of this with nested_app.app_stack([subapp, nested_app]): nested_docs = generate_markdown_docs( nested_app, recursive=recursive, include_hidden=include_hidden, heading_level=nested_heading_level, max_heading_level=max_heading_level, command_chain=nested_command_chain, generate_toc=False, # Don't generate TOC for nested commands flatten_commands=flatten_commands, commands_filter=nested_commands_filter, exclude_commands=sub_exclude_commands, no_root_title=nested_is_intermediate, # Skip title for intermediate paths code_block_title=code_block_title, skip_preamble=nested_is_target or nested_is_intermediate, usage_name=usage_name, ) # Just append the generated docs - no title replacement lines.append(nested_docs) lines.append("") # Join all lines into final document doc = "\n".join(lines).rstrip() + "\n" # Normalize multiple consecutive blank lines to a single blank line # This ensures consistent spacing regardless of how content was assembled import re doc = re.sub(r"\n{3,}", "\n\n", doc) return doc