"""MkDocs plugin for automatic Cyclopts CLI documentation.""" import re from typing import TYPE_CHECKING, Any import yaml from attrs import define, field, validators from cyclopts.docs.markdown import generate_markdown_docs from cyclopts.utils import import_app if TYPE_CHECKING: from mkdocs.config.defaults import MkDocsConfig from mkdocs.structure.files import Files from mkdocs.structure.pages import Page from mkdocs.config import base from mkdocs.config import config_options as c from mkdocs.exceptions import PluginError from mkdocs.plugins import BasePlugin, get_plugin_logger logger = get_plugin_logger(__name__) @define(kw_only=True) class DirectiveOptions: """Configuration for the ::: cyclopts directive.""" module: str = field(validator=validators.instance_of(str)) heading_level: int = field(default=2, validator=validators.instance_of(int)) max_heading_level: int = field(default=6, validator=validators.instance_of(int)) commands: list[str] | None = field(default=None, validator=validators.optional(validators.instance_of(list))) exclude_commands: list[str] | None = field( default=None, validator=validators.optional(validators.instance_of(list)) ) recursive: bool = field(default=True, validator=validators.instance_of(bool)) include_hidden: bool = field(default=False, validator=validators.instance_of(bool)) flatten_commands: bool = field(default=False, validator=validators.instance_of(bool)) generate_toc: bool = field(default=True, validator=validators.instance_of(bool)) code_block_title: bool = field(default=False, validator=validators.instance_of(bool)) skip_preamble: bool = field(default=False, validator=validators.instance_of(bool)) usage_name: str | None = field(default=None, validator=validators.optional(validators.instance_of(str))) @classmethod def from_directive_block( cls, directive_text: str, *, default_heading_level: int | None = None, default_max_heading_level: int | None = None, ) -> "DirectiveOptions": """Parse options from a ::: cyclopts directive block. Expected format: ::: cyclopts module: myapp.cli:app heading_level: 2 max_heading_level: 6 recursive: true commands: - cmd1 - cmd2 Parameters ---------- directive_text : str The directive text to parse. default_heading_level : int | None Default heading level from plugin config. Used if :heading-level: not specified. default_max_heading_level : int | None Default max heading level from plugin config. Used if :max-heading-level: not specified. """ lines = directive_text.strip().split("\n") # Remove the ::: cyclopts line if lines and lines[0].strip().startswith("::: cyclopts"): lines = lines[1:] yaml_content = "\n".join(lines) options = yaml.safe_load(yaml_content) or {} if not isinstance(options, dict): raise TypeError("Invalid YAML in ::: cyclopts directive: expected a dictionary") if "module" not in options: raise ValueError('The "module" option is required for ::: cyclopts directive') if default_heading_level is not None: options.setdefault("heading_level", default_heading_level) if default_max_heading_level is not None: options.setdefault("max_heading_level", default_max_heading_level) # Convert keys with dashes to underscores normalized_options = {key.replace("-", "_"): value for key, value in options.items()} try: return cls(**normalized_options) except TypeError as e: raise ValueError(f"Error creating DirectiveOptions: {e}") from e # Regex to match ::: cyclopts directive blocks # The pattern matches: # - "^::: cyclopts\n" - the directive start on its own line # - "(?:[ \t]+.*\n?)*" - zero or more indented YAML lines (with optional trailing newline for EOF) DIRECTIVE_PATTERN = re.compile( r"^::: cyclopts\n(?:[ \t]+.*\n?)*", re.MULTILINE, ) def process_cyclopts_directives(markdown: str, plugin_config: Any) -> str: """Process all ::: cyclopts directives in markdown content. Parameters ---------- markdown : str The markdown content containing ::: cyclopts directives. plugin_config : CycloptsPluginConfig The plugin configuration with default values. If None, uses DirectiveOptions defaults. Returns ------- str The markdown content with directives replaced by generated documentation. """ # Find all code blocks to exclude from processing code_blocks = [] # Find fenced code blocks (triple backticks or tildes) fenced_pattern = re.compile(r"^[`~]{3,}.*?^[`~]{3,}", re.MULTILINE | re.DOTALL) for match in fenced_pattern.finditer(markdown): code_blocks.append((match.start(), match.end())) # Find indented code blocks (lines starting with 4 spaces or tab) # Indented code blocks are preceded by a blank line and consist of lines starting with 4 spaces/tab lines = markdown.split("\n") in_indented_block = False block_start = 0 current_pos = 0 for i, line in enumerate(lines): line_len = len(line) + 1 # +1 for the newline # Check if this line starts an indented code block if not in_indented_block: # Previous line must be blank (or be the first line) prev_blank = i == 0 or not lines[i - 1].strip() # Current line must start with 4 spaces or a tab and have content is_indented = (line.startswith(" ") or line.startswith("\t")) and line.strip() if prev_blank and is_indented: in_indented_block = True block_start = current_pos else: # Check if we're still in the indented block is_indented = (line.startswith(" ") or line.startswith("\t")) and line.strip() is_blank = not line.strip() # End block if we hit a non-indented, non-blank line if not is_indented and not is_blank: code_blocks.append((block_start, current_pos)) in_indented_block = False current_pos += line_len # If we ended while still in an indented block, add it if in_indented_block: code_blocks.append((block_start, current_pos)) def is_in_code_block(pos: int) -> bool: """Check if a position is inside a code block.""" for start, end in code_blocks: if start <= pos < end: return True return False def replace_directive(match: re.Match) -> str: # Skip if this match is inside a code block if is_in_code_block(match.start()): return match.group(0) directive_text = match.group(0) try: default_heading = plugin_config.default_heading_level if plugin_config else None default_max_heading = plugin_config.default_max_heading_level if plugin_config else None options = DirectiveOptions.from_directive_block( directive_text, default_heading_level=default_heading, default_max_heading_level=default_max_heading, ) app = import_app(options.module) markdown_docs = generate_markdown_docs( app, recursive=options.recursive, include_hidden=options.include_hidden, heading_level=options.heading_level, max_heading_level=options.max_heading_level, generate_toc=options.generate_toc, flatten_commands=options.flatten_commands, commands_filter=options.commands, exclude_commands=options.exclude_commands, no_root_title=True, # Skip root title in plugin context code_block_title=options.code_block_title, skip_preamble=options.skip_preamble, usage_name=options.usage_name, ) return markdown_docs except Exception as e: raise PluginError(f"Error processing ::: cyclopts directive: {e}") from e # Replace all directives in the markdown processed = DIRECTIVE_PATTERN.sub(replace_directive, markdown) return processed class CycloptsPluginConfig(base.Config): # type: ignore[misc] """Configuration schema for the Cyclopts MkDocs plugin.""" default_heading_level = c.Type(int, default=2) # type: ignore[attr-defined] default_max_heading_level = c.Type(int, default=6) # type: ignore[attr-defined] class CycloptsPlugin(BasePlugin[CycloptsPluginConfig]): # type: ignore[misc] """MkDocs plugin to generate Cyclopts CLI documentation. Usage in mkdocs.yml: plugins: - cyclopts: default_heading_level: 2 Usage in Markdown files: ::: cyclopts :module: myapp.cli:app :heading-level: 2 :recursive: true :commands: init, build :exclude-commands: debug """ def on_page_markdown(self, markdown: str, *, page: "Page", config: "MkDocsConfig", files: "Files", **kwargs) -> str: """Process ::: cyclopts directives in markdown content. This event is called after the page's markdown is loaded from file but before it's converted to HTML. """ if "::: cyclopts" not in markdown: return markdown return process_cyclopts_directives(markdown, self.config)