Compare commits
1 Commits
step35/103
...
step35/97-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
425a87bcce |
152
scripts/readme_generator.py
Executable file
152
scripts/readme_generator.py
Executable file
@@ -0,0 +1,152 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
README Generator — Scan codebase and generate/update README.md.
|
||||
|
||||
Reads codebase structure, extracts module docstrings and main entry points,
|
||||
produces a README with: description, installation, usage, API/scripts list.
|
||||
|
||||
Usage:
|
||||
python3 scripts/readme_generator.py
|
||||
python3 scripts/readme_generator.py --dir /path/to/repo
|
||||
python3 scripts/readme_generator.py --dry-run # preview without writing
|
||||
"""
|
||||
import argparse
|
||||
import ast
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
def read_file(path: Path) -> str:
|
||||
try:
|
||||
return path.read_text()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
def extract_module_docstring(path: Path) -> str:
|
||||
try:
|
||||
tree = ast.parse(read_file(path))
|
||||
return ast.get_docstring(tree) or ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
def extract_parser_description(path: Path) -> str:
|
||||
"""Extract the first ArgumentParser description found in the file."""
|
||||
try:
|
||||
content = read_file(path)
|
||||
for line in content.split('\n'):
|
||||
if 'ArgumentParser' in content[max(0,content.index(line)-100):content.index(line)+200] and 'description=' in line:
|
||||
desc_part = line.split('description=')[1]
|
||||
desc = desc_part.strip().rstrip(',').strip('"\'')
|
||||
return desc
|
||||
return ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
def scan_python_files(root: Path) -> List[Dict]:
|
||||
"""Collect Python files (exclude tests) with basic metadata."""
|
||||
files = []
|
||||
for path in root.rglob('*.py'):
|
||||
rel = path.relative_to(root)
|
||||
parts = rel.parts
|
||||
if any(p.startswith('test_') or p in ('__pycache__', '.git', 'venv', '.venv', '.pytest_cache') for p in parts):
|
||||
continue
|
||||
files.append({
|
||||
'path': str(rel),
|
||||
'docstring': extract_module_docstring(path),
|
||||
'parser_desc': extract_parser_description(path),
|
||||
'name': path.name,
|
||||
})
|
||||
return sorted(files, key=lambda x: x['path'])
|
||||
|
||||
def detect_entry_point(file_info: Dict) -> bool:
|
||||
"""A file is an entry point if it has a main block or argparse."""
|
||||
path = Path(file_info['path'])
|
||||
name = path.name
|
||||
return name in ('__main__.py', 'main.py') or bool(file_info['parser_desc']) or path.parts[0] == 'bin'
|
||||
|
||||
def generate_readme(root_dir: str, output_path: Optional[str] = None, dry_run: bool = False) -> str:
|
||||
root = Path(root_dir).resolve()
|
||||
py_files = scan_python_files(root)
|
||||
|
||||
sections = []
|
||||
repo_name = root.name
|
||||
|
||||
sections.append(f"# {repo_name}\n")
|
||||
|
||||
if py_files:
|
||||
main_doc = py_files[0]['docstring'].strip()
|
||||
if main_doc:
|
||||
sections.append(main_doc + "\n")
|
||||
else:
|
||||
sections.append("A Python project.\n")
|
||||
else:
|
||||
sections.append("A Python project.\n")
|
||||
|
||||
sections.append("## Installation\n")
|
||||
if (root / "requirements.txt").exists():
|
||||
sections.append("```bash\ncp .env.example .env # if present\npip install -r requirements.txt\n```\n")
|
||||
elif (root / "pyproject.toml").exists():
|
||||
sections.append("```bash\npip install -e .\n```\n")
|
||||
else:
|
||||
sections.append("```bash\npip install -e .\n```\n")
|
||||
|
||||
sections.append("## Usage\n")
|
||||
entry_scripts = [f for f in py_files if detect_entry_point(f)]
|
||||
if entry_scripts:
|
||||
for f in entry_scripts[:8]:
|
||||
name = f['name']
|
||||
if f['parser_desc']:
|
||||
sections.append(f"### {name}\n{f['parser_desc']}\n")
|
||||
else:
|
||||
sections.append(f"### {name}\n```bash\npython3 {f['path']}\n```\n")
|
||||
else:
|
||||
sections.append("See `scripts/` directory for available tools.\n")
|
||||
|
||||
sections.append("## Scripts\n")
|
||||
if entry_scripts:
|
||||
for f in entry_scripts[:15]:
|
||||
desc = f['docstring'].strip().split('\n')[0] if f['docstring'].strip() else "Utility script."
|
||||
sections.append(f"- **{f['name']}**: {desc}")
|
||||
else:
|
||||
sections.append("- No entry-point scripts detected.\n")
|
||||
|
||||
sections.append("\n## Directory Structure\n")
|
||||
top_dirs = sorted([
|
||||
d.name for d in root.iterdir()
|
||||
if d.is_dir() and not d.name.startswith('.') and d.name not in ('__pycache__', 'venv', '.venv', 'node_modules')
|
||||
])
|
||||
sections.append("```\n")
|
||||
for d in top_dirs[:12]:
|
||||
sections.append(f"{d}/")
|
||||
sections.append("```\n")
|
||||
|
||||
readme_content = "\n".join(sections)
|
||||
|
||||
if dry_run:
|
||||
print(json.dumps({
|
||||
"repo": repo_name,
|
||||
"sections": len(sections),
|
||||
"chars": len(readme_content),
|
||||
"python_files": len(py_files),
|
||||
"entry_scripts": sum(1 for f in py_files if detect_entry_point(f)),
|
||||
}, indent=2))
|
||||
return ""
|
||||
|
||||
if output_path is None:
|
||||
output_path = root / "README.md"
|
||||
else:
|
||||
output_path = Path(output_path)
|
||||
|
||||
output_path.write_text(readme_content)
|
||||
print(f"README {'updated' if output_path.exists() else 'created'}: {output_path} ({len(readme_content)} bytes)")
|
||||
return str(output_path)
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Generate or update README.md from codebase structure.")
|
||||
parser.add_argument("--dir", default=".", help="Directory to scan (default: current)")
|
||||
parser.add_argument("--output", help="Output README path (default: README.md in scanned dir)")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Preview without writing")
|
||||
args = parser.parse_args()
|
||||
|
||||
generate_readme(args.dir, args.output, args.dry_run)
|
||||
Reference in New Issue
Block a user