Skip to content

Commit 2583bc1

Browse files
committed
generate full CLI docs from click's metadata and also added it in precommit hooks
1 parent 0dc1a48 commit 2583bc1

File tree

5 files changed

+609
-0
lines changed

5 files changed

+609
-0
lines changed

.pre-commit-config.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,13 @@ repos:
2626

2727
- repo: local
2828
hooks:
29+
- id: generate-cli-docs
30+
name: generate CLI documentation
31+
entry: python dev/generate_cli_docs.py
32+
language: system
33+
files: ^(python/cocoindex/cli\.py|dev/generate_cli_docs\.py)$
34+
pass_filenames: false
35+
2936
- id: maturin-develop
3037
name: maturin develop
3138
entry: maturin develop -E all,dev

dev/README.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# Development Scripts
2+
3+
This directory contains development and maintenance scripts for the CocoIndex project.
4+
5+
## Scripts
6+
7+
### `generate_cli_docs.py`
8+
9+
Automatically generates CLI documentation from the CocoIndex Click commands.
10+
11+
**Usage:**
12+
13+
```bash
14+
python dev/generate_cli_docs.py
15+
```
16+
17+
**What it does:**
18+
19+
- Extracts help messages from all Click commands in `python/cocoindex/cli.py`
20+
- Generates comprehensive Markdown documentation with properly formatted tables
21+
- Saves the output to `docs/docs/core/cli-reference.md`
22+
- Only updates the file if content has changed (avoids unnecessary git diffs)
23+
24+
**Integration:**
25+
26+
- Runs automatically as a pre-commit hook when `python/cocoindex/cli.py` is modified
27+
- The generated documentation is imported into `docs/docs/core/cli.mdx` via MDX import
28+
29+
**Dependencies:**
30+
31+
- `md-click` package for extracting Click help information
32+
- `cocoindex` package must be importable (the CLI module)
33+
34+
This ensures that CLI documentation is always kept in sync with the actual command-line interface.

dev/generate_cli_docs.py

Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Script to generate CLI documentation from CocoIndex Click commands.
4+
5+
This script uses md-click as the foundation but generates enhanced markdown
6+
documentation that's suitable for inclusion in the CocoIndex documentation site.
7+
"""
8+
9+
import sys
10+
import os
11+
from pathlib import Path
12+
import re
13+
from typing import Dict, List, Any
14+
15+
# Add the cocoindex python directory to the path
16+
project_root = Path(__file__).parent.parent
17+
python_path = project_root / "python"
18+
sys.path.insert(0, str(python_path))
19+
20+
try:
21+
import md_click
22+
from cocoindex.cli import cli
23+
except ImportError as e:
24+
print(f"Error importing required modules: {e}")
25+
print("Make sure to run this script from the project root and install dependencies")
26+
sys.exit(1)
27+
28+
29+
def clean_usage_line(usage: str) -> str:
30+
"""Clean up the usage line to remove 'cli' and make it generic."""
31+
# Replace 'cli' with 'cocoindex' in usage lines
32+
return usage.replace("Usage: cli ", "Usage: cocoindex ")
33+
34+
35+
def format_options_section(help_text: str) -> str:
36+
"""Extract and format the options section."""
37+
lines = help_text.split("\n")
38+
options_start = None
39+
commands_start = None
40+
41+
for i, line in enumerate(lines):
42+
if line.strip() == "Options:":
43+
options_start = i
44+
elif line.strip() == "Commands:":
45+
commands_start = i
46+
break
47+
48+
if options_start is None:
49+
return ""
50+
51+
# Extract options section
52+
end_idx = commands_start if commands_start else len(lines)
53+
options_lines = lines[options_start + 1 : end_idx] # Skip "Options:" header
54+
55+
# Parse options - each option starts with exactly 2 spaces and a dash
56+
formatted_options = []
57+
current_option = None
58+
current_description = []
59+
60+
for line in options_lines:
61+
if not line.strip(): # Empty line
62+
continue
63+
64+
# Check if this is a new option line (starts with exactly 2 spaces then -)
65+
if line.startswith(" -") and not line.startswith(" "):
66+
# Save previous option if exists
67+
if current_option is not None:
68+
desc = " ".join(current_description).strip()
69+
formatted_options.append(f"| `{current_option}` | {desc} |")
70+
71+
# Remove the leading 2 spaces
72+
content = line[2:]
73+
74+
# Find the position where we have multiple consecutive spaces (start of description)
75+
match = re.search(r"\s{2,}", content)
76+
if match:
77+
# Split at the first occurrence of multiple spaces
78+
option_part = content[: match.start()]
79+
desc_part = content[match.end() :]
80+
current_option = option_part.strip()
81+
current_description = [desc_part.strip()] if desc_part.strip() else []
82+
else:
83+
# No description on this line, just the option
84+
current_option = content.strip()
85+
current_description = []
86+
else:
87+
# Continuation line (starts with more than 2 spaces)
88+
if current_option is not None and line.strip():
89+
current_description.append(line.strip())
90+
91+
# Add last option
92+
if current_option is not None:
93+
desc = " ".join(current_description).strip()
94+
formatted_options.append(f"| `{current_option}` | {desc} |")
95+
96+
if formatted_options:
97+
header = "| Option | Description |\n|--------|-------------|"
98+
return f"{header}\n" + "\n".join(formatted_options) + "\n"
99+
100+
return ""
101+
102+
103+
def format_commands_section(help_text: str) -> str:
104+
"""Extract and format the commands section."""
105+
lines = help_text.split("\n")
106+
commands_start = None
107+
108+
for i, line in enumerate(lines):
109+
if line.strip() == "Commands:":
110+
commands_start = i
111+
break
112+
113+
if commands_start is None:
114+
return ""
115+
116+
# Extract commands section
117+
commands_lines = lines[commands_start + 1 :]
118+
119+
# Parse commands - each command starts with 2 spaces then the command name
120+
formatted_commands = []
121+
122+
for line in commands_lines:
123+
if not line.strip(): # Empty line
124+
continue
125+
126+
# Check if this is a command line (starts with 2 spaces + command name)
127+
match = re.match(r"^ (\w+)\s{2,}(.+)$", line)
128+
if match:
129+
command = match.group(1)
130+
description = match.group(2).strip()
131+
# Truncate long descriptions
132+
if len(description) > 80:
133+
description = description[:77] + "..."
134+
formatted_commands.append(f"| `{command}` | {description} |")
135+
136+
if formatted_commands:
137+
header = "| Command | Description |\n|---------|-------------|"
138+
return f"{header}\n" + "\n".join(formatted_commands) + "\n"
139+
140+
return ""
141+
142+
143+
def extract_description(help_text: str) -> str:
144+
"""Extract the main description from help text."""
145+
lines = help_text.split("\n")
146+
147+
# Find the description between usage and options/commands
148+
description_lines = []
149+
in_description = False
150+
151+
for line in lines:
152+
if line.startswith("Usage:"):
153+
in_description = True
154+
continue
155+
elif line.strip() in ["Options:", "Commands:"]:
156+
break
157+
elif in_description and line.strip():
158+
description_lines.append(line.strip())
159+
160+
return "\n\n".join(description_lines) if description_lines else ""
161+
162+
163+
def generate_command_docs(docs: List[Dict[str, Any]]) -> str:
164+
"""Generate markdown documentation for all commands."""
165+
166+
# Separate main CLI from subcommands
167+
main_cli = None
168+
subcommands = []
169+
170+
for doc in docs:
171+
parent = doc.get("parent", "")
172+
if not parent:
173+
main_cli = doc
174+
else:
175+
subcommands.append(doc)
176+
177+
markdown_content = []
178+
179+
if main_cli:
180+
# Generate main CLI documentation
181+
help_text = main_cli["help"]
182+
usage = clean_usage_line(main_cli["usage"])
183+
description = extract_description(help_text)
184+
185+
markdown_content.append("# CLI Commands Reference")
186+
markdown_content.append("")
187+
markdown_content.append(
188+
"This page contains the detailed help information for all CocoIndex CLI commands."
189+
)
190+
markdown_content.append("")
191+
192+
if description:
193+
markdown_content.append(f"## Overview")
194+
markdown_content.append("")
195+
markdown_content.append(description)
196+
markdown_content.append("")
197+
198+
# Add usage
199+
markdown_content.append("## Usage")
200+
markdown_content.append("")
201+
markdown_content.append(f"```sh")
202+
markdown_content.append(usage)
203+
markdown_content.append("```")
204+
markdown_content.append("")
205+
206+
# Add global options
207+
options_section = format_options_section(help_text)
208+
if options_section:
209+
markdown_content.append("## Global Options")
210+
markdown_content.append("")
211+
markdown_content.append(options_section)
212+
markdown_content.append("")
213+
214+
# Add commands overview
215+
commands_section = format_commands_section(help_text)
216+
if commands_section:
217+
markdown_content.append("## Commands")
218+
markdown_content.append("")
219+
markdown_content.append(commands_section)
220+
markdown_content.append("")
221+
222+
# Generate subcommand documentation
223+
markdown_content.append("## Command Details")
224+
markdown_content.append("")
225+
226+
for doc in sorted(subcommands, key=lambda x: x["command"].name):
227+
command_name = doc["command"].name
228+
help_text = doc["help"]
229+
usage = clean_usage_line(doc["usage"])
230+
description = extract_description(help_text)
231+
232+
markdown_content.append(f"### `{command_name}`")
233+
markdown_content.append("")
234+
235+
if description:
236+
markdown_content.append(description)
237+
markdown_content.append("")
238+
239+
# Add usage
240+
markdown_content.append("**Usage:**")
241+
markdown_content.append("")
242+
markdown_content.append(f"```bash")
243+
markdown_content.append(usage)
244+
markdown_content.append("```")
245+
markdown_content.append("")
246+
247+
# Add options if any
248+
options_section = format_options_section(help_text)
249+
if options_section:
250+
# Remove the "## Options" header since it's a subsection
251+
markdown_content.append("**Options:**")
252+
markdown_content.append("")
253+
markdown_content.append(options_section)
254+
markdown_content.append("")
255+
256+
markdown_content.append("---")
257+
markdown_content.append("")
258+
259+
return "\n".join(markdown_content)
260+
261+
262+
def main():
263+
"""Generate CLI documentation and save to file."""
264+
print("Generating CocoIndex CLI documentation...")
265+
266+
try:
267+
# Generate documentation using md-click
268+
docs_generator = md_click.main.recursive_help(cli)
269+
docs = list(docs_generator)
270+
271+
print(f"Found {len(docs)} CLI commands to document")
272+
273+
# Generate markdown content
274+
markdown_content = generate_command_docs(docs)
275+
276+
# Determine output path
277+
docs_dir = project_root / "docs" / "docs" / "core"
278+
output_file = docs_dir / "cli-reference.md"
279+
280+
# Ensure directory exists
281+
docs_dir.mkdir(parents=True, exist_ok=True)
282+
283+
# Write the generated documentation
284+
content_changed = True
285+
if output_file.exists():
286+
with open(output_file, "r", encoding="utf-8") as f:
287+
existing_content = f.read()
288+
content_changed = existing_content != markdown_content
289+
290+
if content_changed:
291+
with open(output_file, "w", encoding="utf-8") as f:
292+
f.write(markdown_content)
293+
294+
print(f"CLI documentation generated successfully at: {output_file}")
295+
print(
296+
f"Generated {len(markdown_content.splitlines())} lines of documentation"
297+
)
298+
else:
299+
print(f"CLI documentation is up to date at: {output_file}")
300+
301+
except Exception as e:
302+
print(f"Error generating documentation: {e}")
303+
import traceback
304+
305+
traceback.print_exc()
306+
sys.exit(1)
307+
308+
309+
if __name__ == "__main__":
310+
main()

0 commit comments

Comments
 (0)