Spaces:
Sleeping
Sleeping
| """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__) | |
| 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))) | |
| 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) | |