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