Initial upload: Doc2Man CLI tool with parsers, generators, and tests
This commit is contained in:
296
doc2man/cli.py
Normal file
296
doc2man/cli.py
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
"""CLI interface for Doc2Man."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import click
|
||||||
|
from rich.console import Console
|
||||||
|
from rich.logging import RichHandler
|
||||||
|
from rich.theme import Theme
|
||||||
|
|
||||||
|
from doc2man import __version__
|
||||||
|
from doc2man.config import load_config
|
||||||
|
from doc2man.preview import preview_output
|
||||||
|
from doc2man.generators.man import generate_man_page
|
||||||
|
from doc2man.generators.markdown import generate_markdown
|
||||||
|
from doc2man.generators.html import generate_html
|
||||||
|
from doc2man.parsers.python import parse_python_file
|
||||||
|
from doc2man.parsers.go import parse_go_file
|
||||||
|
from doc2man.parsers.javascript import parse_javascript_file
|
||||||
|
|
||||||
|
console = Console(theme=Theme({"logging.level": "white"}))
|
||||||
|
|
||||||
|
|
||||||
|
def setup_logging(verbosity: int) -> None:
|
||||||
|
"""Set up logging based on verbosity level."""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
if verbosity == 0:
|
||||||
|
level = logging.WARNING
|
||||||
|
elif verbosity == 1:
|
||||||
|
level = logging.INFO
|
||||||
|
else:
|
||||||
|
level = logging.DEBUG
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=level,
|
||||||
|
format="%(message)s",
|
||||||
|
handlers=[RichHandler(console=console, show_time=False)],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def detect_language(file_path: Path) -> str:
|
||||||
|
"""Detect programming language based on file extension."""
|
||||||
|
suffix = file_path.suffix.lower()
|
||||||
|
if suffix == ".py":
|
||||||
|
return "python"
|
||||||
|
elif suffix == ".go":
|
||||||
|
return "go"
|
||||||
|
elif suffix in (".js", ".ts", ".jsx", ".tsx"):
|
||||||
|
return "javascript"
|
||||||
|
else:
|
||||||
|
raise click.ClickException(f"Unsupported file extension: {suffix}")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_source_file(file_path: Path, language: Optional[str] = None):
|
||||||
|
"""Parse a source file and extract documentation."""
|
||||||
|
if language is None:
|
||||||
|
language = detect_language(file_path)
|
||||||
|
|
||||||
|
if language == "python":
|
||||||
|
return parse_python_file(file_path)
|
||||||
|
elif language == "go":
|
||||||
|
return parse_go_file(file_path)
|
||||||
|
elif language == "javascript":
|
||||||
|
return parse_javascript_file(file_path)
|
||||||
|
else:
|
||||||
|
raise click.ClickException(f"Unsupported language: {language}")
|
||||||
|
|
||||||
|
|
||||||
|
def generate_output(parsed_data, output_format: str, output_path: Path, template_path: Optional[Path] = None):
|
||||||
|
"""Generate documentation in the specified format."""
|
||||||
|
if output_format == "man":
|
||||||
|
return generate_man_page(parsed_data, output_path, template_path)
|
||||||
|
elif output_format == "markdown":
|
||||||
|
return generate_markdown(parsed_data, output_path, template_path)
|
||||||
|
elif output_format == "html":
|
||||||
|
return generate_html(parsed_data, output_path, template_path)
|
||||||
|
else:
|
||||||
|
raise click.ClickException(f"Unsupported output format: {output_format}")
|
||||||
|
|
||||||
|
|
||||||
|
@click.group()
|
||||||
|
@click.version_option(version=__version__)
|
||||||
|
@click.option(
|
||||||
|
"--verbose", "-v", count=True, help="Increase verbosity (can be used multiple times)"
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--quiet", "-q", is_flag=True, help="Suppress all output except errors"
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--config", "-c", type=click.Path(exists=True, dir_okay=False), help="Path to configuration file"
|
||||||
|
)
|
||||||
|
@click.pass_context
|
||||||
|
def main(ctx: click.Context, verbose: int, quiet: bool, config: str):
|
||||||
|
"""Doc2Man - Generate man pages, Markdown, and HTML documentation from docstrings."""
|
||||||
|
if quiet:
|
||||||
|
verbose = -1
|
||||||
|
|
||||||
|
setup_logging(verbose)
|
||||||
|
|
||||||
|
ctx.ensure_object(dict)
|
||||||
|
if ctx.obj is None:
|
||||||
|
ctx.obj = {}
|
||||||
|
ctx.obj["verbose"] = verbose
|
||||||
|
ctx.obj["quiet"] = quiet
|
||||||
|
ctx.obj["config"] = config
|
||||||
|
|
||||||
|
config_data = load_config(config) if config else {}
|
||||||
|
ctx.obj["config_data"] = config_data
|
||||||
|
|
||||||
|
|
||||||
|
@main.command()
|
||||||
|
@click.argument(
|
||||||
|
"input_paths", nargs=-1, type=click.Path(exists=True, dir_okay=True, readable=True)
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--output", "-o", type=click.Path(dir_okay=False), help="Output file path"
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--format",
|
||||||
|
"-f",
|
||||||
|
type=click.Choice(["man", "markdown", "html"]),
|
||||||
|
default="man",
|
||||||
|
help="Output format",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--template", "-t", type=click.Path(exists=True, dir_okay=False), help="Custom template file"
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--dry-run", is_flag=True, help="Show output without writing files"
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--fail-on-error", is_flag=True, help="Exit with non-zero code on any error"
|
||||||
|
)
|
||||||
|
@click.pass_context
|
||||||
|
def generate(
|
||||||
|
ctx: click.Context,
|
||||||
|
input_paths: tuple,
|
||||||
|
output: str,
|
||||||
|
format: str,
|
||||||
|
template: str,
|
||||||
|
dry_run: bool,
|
||||||
|
fail_on_error: bool,
|
||||||
|
):
|
||||||
|
"""Generate documentation from source files."""
|
||||||
|
config_data = ctx.obj.get("config_data", {}) if ctx.obj else {}
|
||||||
|
|
||||||
|
if not input_paths:
|
||||||
|
if "input" in config_data:
|
||||||
|
input_paths = tuple(Path(p) for p in config_data["input"])
|
||||||
|
else:
|
||||||
|
raise click.ClickException("No input paths provided")
|
||||||
|
|
||||||
|
if not output:
|
||||||
|
if "output" in config_data:
|
||||||
|
output = config_data["output"]
|
||||||
|
else:
|
||||||
|
raise click.ClickException("No output path provided")
|
||||||
|
|
||||||
|
format = format or config_data.get("format", "man")
|
||||||
|
template = template or config_data.get("template")
|
||||||
|
|
||||||
|
all_parsed_data = []
|
||||||
|
|
||||||
|
for input_path_str in input_paths:
|
||||||
|
input_path = Path(input_path_str)
|
||||||
|
|
||||||
|
if input_path.is_file():
|
||||||
|
files_to_process = [input_path]
|
||||||
|
else:
|
||||||
|
files_to_process = list(input_path.rglob("*"))
|
||||||
|
files_to_process = [f for f in files_to_process if f.is_file()]
|
||||||
|
|
||||||
|
exclusions = config_data.get("exclusions", [])
|
||||||
|
|
||||||
|
for file_path in files_to_process:
|
||||||
|
if file_path.suffix.lower() in [".py", ".go", ".js", ".ts", ".jsx", ".tsx"]:
|
||||||
|
if any(str(file_path).match(excl) for excl in exclusions):
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed_data = parse_source_file(file_path)
|
||||||
|
if parsed_data:
|
||||||
|
all_parsed_data.append(
|
||||||
|
{"file": str(file_path), "data": parsed_data}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
if fail_on_error:
|
||||||
|
raise click.ClickException(f"Error parsing {file_path}: {e}")
|
||||||
|
else:
|
||||||
|
console.print(f"[yellow]Warning:[/] Error parsing {file_path}: {e}")
|
||||||
|
|
||||||
|
if not all_parsed_data:
|
||||||
|
raise click.ClickException("No documentation found in input files")
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
for item in all_parsed_data:
|
||||||
|
console.print(f"\n[bold]=== {item['file']} ===[/bold]")
|
||||||
|
preview_output(item["data"], format, console)
|
||||||
|
return
|
||||||
|
|
||||||
|
output_path = Path(output)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if format == "man":
|
||||||
|
generate_man_page(all_parsed_data, output_path, Path(template) if template else None)
|
||||||
|
elif format == "markdown":
|
||||||
|
generate_markdown(all_parsed_data, output_path, Path(template) if template else None)
|
||||||
|
elif format == "html":
|
||||||
|
generate_html(all_parsed_data, output_path, Path(template) if template else None)
|
||||||
|
except Exception as e:
|
||||||
|
if fail_on_error:
|
||||||
|
raise click.ClickException(f"Error generating output: {e}")
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
|
console.print(f"[green]Generated documentation:[/] {output_path}")
|
||||||
|
|
||||||
|
|
||||||
|
@main.command()
|
||||||
|
@click.argument("input_paths", nargs=-1, type=click.Path(exists=True, dir_okay=True, readable=True))
|
||||||
|
@click.option(
|
||||||
|
"--format", "-f", type=click.Choice(["man", "markdown", "html"]), default="man", help="Preview format"
|
||||||
|
)
|
||||||
|
@click.pass_context
|
||||||
|
def preview(ctx: click.Context, input_paths: tuple, format: str):
|
||||||
|
"""Preview documentation in the terminal."""
|
||||||
|
config_data = ctx.obj.get("config_data", {}) if ctx.obj else {}
|
||||||
|
|
||||||
|
if not input_paths:
|
||||||
|
if "input" in config_data:
|
||||||
|
input_paths = tuple(Path(p) for p in config_data["input"])
|
||||||
|
else:
|
||||||
|
raise click.ClickException("No input paths provided")
|
||||||
|
|
||||||
|
for input_path_str in input_paths:
|
||||||
|
input_path = Path(input_path_str)
|
||||||
|
|
||||||
|
if input_path.is_file():
|
||||||
|
files_to_process = [input_path]
|
||||||
|
else:
|
||||||
|
files_to_process = list(input_path.rglob("*"))
|
||||||
|
files_to_process = [f for f in files_to_process if f.is_file()]
|
||||||
|
|
||||||
|
for file_path in files_to_process:
|
||||||
|
if file_path.suffix.lower() in [".py", ".go", ".js", ".ts"]:
|
||||||
|
try:
|
||||||
|
parsed_data = parse_source_file(file_path)
|
||||||
|
if parsed_data:
|
||||||
|
console.print(f"\n[bold]=== {file_path} ===[/bold]")
|
||||||
|
preview_output(parsed_data, format, console)
|
||||||
|
except Exception as e:
|
||||||
|
console.print(f"[yellow]Warning:[/] Error parsing {file_path}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
@main.command()
|
||||||
|
@click.argument("input_file", type=click.Path(exists=True, dir_okay=False))
|
||||||
|
@click.option(
|
||||||
|
"--format", "-f", type=click.Choice(["man", "markdown", "html"]), default="man", help="Output format"
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--output", "-o", type=click.Path(dir_okay=False), help="Output file path"
|
||||||
|
)
|
||||||
|
@click.pass_context
|
||||||
|
def parse(ctx: click.Context, input_file: str, format: str, output: str):
|
||||||
|
"""Parse a single source file and display parsed metadata."""
|
||||||
|
file_path = Path(input_file)
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed_data = parse_source_file(file_path)
|
||||||
|
except Exception as e:
|
||||||
|
raise click.ClickException(f"Error parsing {file_path}: {e}")
|
||||||
|
|
||||||
|
if not parsed_data:
|
||||||
|
raise click.ClickException(f"No documentation found in {file_path}")
|
||||||
|
|
||||||
|
console.print(f"\n[bold]=== Parsed Metadata for {file_path} ===[/bold]")
|
||||||
|
console.print(parsed_data)
|
||||||
|
|
||||||
|
if output:
|
||||||
|
output_path = Path(output)
|
||||||
|
try:
|
||||||
|
if format == "man":
|
||||||
|
generate_man_page([{"file": str(file_path), "data": parsed_data}], output_path, None)
|
||||||
|
elif format == "markdown":
|
||||||
|
generate_markdown([{"file": str(file_path), "data": parsed_data}], output_path, None)
|
||||||
|
elif format == "html":
|
||||||
|
generate_html([{"file": str(file_path), "data": parsed_data}], output_path, None)
|
||||||
|
console.print(f"[green]Output written to:[/] {output_path}")
|
||||||
|
except Exception as e:
|
||||||
|
raise click.ClickException(f"Error generating output: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user