From c2470c07050119b48f85be32e8074f03fad3a6d9 Mon Sep 17 00:00:00 2001 From: 7000pctAUTO Date: Sat, 31 Jan 2026 00:54:21 +0000 Subject: [PATCH] Initial upload: Doc2Man CLI tool with parsers, generators, and tests --- doc2man/cli.py | 296 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 296 insertions(+) create mode 100644 doc2man/cli.py diff --git a/doc2man/cli.py b/doc2man/cli.py new file mode 100644 index 0000000..a61d91d --- /dev/null +++ b/doc2man/cli.py @@ -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()