Spaces:
Sleeping
Sleeping
File size: 9,769 Bytes
53dbcc1 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 | """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)
|