Initial upload: Doc2Man CLI tool with parsers, generators, and tests
Some checks failed
CI / build (push) Has been cancelled
CI / test (push) Has been cancelled

This commit is contained in:
2026-01-31 00:54:21 +00:00
parent 76a4caf692
commit c2470c0705

296
doc2man/cli.py Normal file
View 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()