From 1bc1900d95d75c9960c1cdf78b53bae57ce826c9 Mon Sep 17 00:00:00 2001 From: 7000pctAUTO Date: Sun, 1 Feb 2026 16:38:19 +0000 Subject: [PATCH] fix: resolve CI test failures --- src/cli.py | 406 +++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 348 insertions(+), 58 deletions(-) diff --git a/src/cli.py b/src/cli.py index e517869..25f9c86 100644 --- a/src/cli.py +++ b/src/cli.py @@ -1,75 +1,365 @@ +"""CLI interface for LocalAPI Docs.""" + +import json +import logging +import os +import sys +import threading +import webbrowser +from datetime import datetime +from http.server import HTTPServer, SimpleHTTPRequestHandler +from pathlib import Path +from typing import Optional + import click -from .core.parser import parse_openapi_spec -from .core.generator import generate_docs -from .core.models import APISpec, Endpoint, RequestExample, ResponseExample -from .utils.search import search_endpoints + +from src.core.parser import OpenAPIParser, ParseError, SpecValidationError +from src.templates import HTML_TEMPLATE, MARKDOWN_TEMPLATE, JSON_TEMPLATE +from src.utils.examples import ExampleGenerator +from src.utils.search import SearchIndex + + +def setup_logging(verbose: bool = False): + """Set up logging configuration.""" + import logging + log_handler = logging.StreamHandler(sys.stdout) + if verbose: + log_handler.setFormatter( + logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") + ) + logging.basicConfig(level=logging.DEBUG, handlers=[log_handler]) + else: + log_handler.setFormatter(logging.Formatter("%(message)s")) + logging.basicConfig(level=logging.INFO, handlers=[log_handler]) @click.group() -def main(): - """LocalAPI Docs - Generate local API documentation from OpenAPI specs.""" - pass +@click.option("--verbose", "-v", is_flag=True, help="Enable verbose output") +@click.pass_context +def main(ctx: click.Context, verbose: bool): + """LocalAPI Docs - Generate local, privacy-focused API documentation.""" + ctx.ensure_object(dict) + ctx.obj["verbose"] = verbose + setup_logging(verbose) -@main.command() -@click.argument('spec_file', type=click.Path(exists=True)) -@click.option('--host', '-h', default='127.0.0.1', help='Host to bind to') -@click.option('--port', '-p', default=8080, help='Port to serve on') -@click.option('--no-browser', is_flag=True, help='Don\'t open browser automatically') -def serve(spec_file, host, port, no_browser): +@main.command("serve") +@click.argument("spec_file", type=click.Path()) +@click.option("--host", "-h", default="127.0.0.1", help="Host to bind to") +@click.option("--port", "-p", default=8080, type=int, help="Port to serve on") +@click.option("--no-browser", is_flag=True, help="Don't open browser automatically") +@click.pass_context +def serve(ctx: click.Context, spec_file: str, host: str, port: int, no_browser: bool): """Start an interactive HTML documentation server.""" - from .templates.html_template import generate_html_server - generate_html_server(spec_file, host, port, not no_browser) + if ctx.obj is None: + ctx.obj = {} + verbose = ctx.obj.get("verbose", False) + + if not Path(spec_file).exists(): + click.echo(click.style("Error: ", fg="red") + f"Spec file not found: {spec_file}") + sys.exit(1) + + try: + parser = OpenAPIParser(spec_file) + spec_data = parser.parse_with_examples() + + output_dir = Path(spec_file).parent / ".localapi-docs" + output_dir.mkdir(exist_ok=True) + + endpoints_by_tag: dict = {} + for ep in spec_data["endpoints"]: + tags = ep.get("tags", ["other"]) + for tag in tags: + if tag not in endpoints_by_tag: + endpoints_by_tag[tag] = [] + endpoints_by_tag[tag].append(ep) + + html_content = HTML_TEMPLATE.render( + spec=spec_data["spec"], + endpoints=spec_data["endpoints"], + endpoints_by_tag=endpoints_by_tag, + schemas=spec_data["schemas"], + tags=spec_data["tags"], + timestamp=datetime.now().isoformat(), + ) + + html_file = output_dir / "index.html" + html_file.write_text(html_content) + + search_index = SearchIndex() + search_index.build(spec_data["spec"]) + search_index_file = output_dir / "search.json" + search_index_file.write_text(json.dumps({ + "endpoints": spec_data["endpoints"], + "schemas": spec_data["schemas"], + "tags": spec_data["tags"], + })) + + if not no_browser: + def open_browser(): + webbrowser.open(f"http://{host}:{port}") + threading.Timer(1.0, open_browser).start() + + os.chdir(output_dir) + server = HTTPServer((host, port), SimpleHTTPRequestHandler) + + click.echo(f"Documentation server running at http://{host}:{port}") + click.echo(f"Serving from: {output_dir}") + click.echo("Press Ctrl+C to stop") + + server.serve_forever() + except ParseError as e: + click.echo(click.style("Error: ", fg="red") + f"Parse error: {e}") + sys.exit(1) + except KeyboardInterrupt: + click.echo("Server stopped") + except Exception as e: + click.echo(click.style("Error: ", fg="red") + f"Unexpected error: {e}") + if verbose: + raise + sys.exit(1) -@main.command() -@click.argument('spec_file', type=click.Path(exists=True)) -@click.option('--output', '-o', help='Output file or directory') -@click.option('--format', '-f', type=click.Choice(['html', 'markdown', 'json', 'all']), default='html', help='Output format') -@click.option('--open', is_flag=True, help='Open the generated file in browser') -def generate(spec_file, output, format, open): +@main.command("generate") +@click.argument("spec_file", type=click.Path()) +@click.option("--output", "-o", type=click.Path(), help="Output file or directory") +@click.option("--format", "-f", type=click.Choice(["html", "markdown", "json", "all"]), + default="html", help="Output format") +@click.option("--open", "open_file", is_flag=True, help="Open the generated file in browser") +@click.pass_context +def generate(ctx: click.Context, spec_file: str, output: Optional[str], + format: str, open_file: bool): """Generate static documentation in various formats.""" - generate_docs(spec_file, output, format, open) + if ctx.obj is None: + ctx.obj = {} + verbose = ctx.obj.get("verbose", False) + + if not Path(spec_file).exists(): + click.echo(click.style("Error: ", fg="red") + f"Spec file not found: {spec_file}") + sys.exit(1) + + try: + parser = OpenAPIParser(spec_file) + spec_data = parser.parse_with_examples() + + endpoints_by_tag = {} + for ep in spec_data["endpoints"]: + tags = ep.get("tags", ["other"]) + for tag in tags: + if tag not in endpoints_by_tag: + endpoints_by_tag[tag] = [] + endpoints_by_tag[tag].append(ep) + + output_path = Path(output) if output else Path.cwd() + + if format in ["html", "all"]: + html_content = HTML_TEMPLATE.render( + spec=spec_data["spec"], + endpoints=spec_data["endpoints"], + endpoints_by_tag=endpoints_by_tag, + schemas=spec_data["schemas"], + tags=spec_data["tags"], + timestamp=datetime.now().isoformat(), + ) + + if format == "all": + html_file = output_path / "docs" / "index.html" + html_file.parent.mkdir(exist_ok=True) + else: + if output_path.is_dir(): + html_file = output_path / "api-docs.html" + else: + html_file = output_path + + html_file.write_text(html_content) + click.echo(f"Generated HTML documentation: {html_file}") + + if open_file: + webbrowser.open(f"file://{html_file.absolute()}") + + if format in ["markdown", "all"]: + md_content = MARKDOWN_TEMPLATE.render( + spec=spec_data["spec"], + endpoints=spec_data["endpoints"], + endpoints_by_tag=endpoints_by_tag if endpoints_by_tag else {}, + schemas=spec_data["schemas"], + tags=spec_data["tags"], + timestamp=datetime.now().isoformat(), + ) + + if format == "all": + md_file = output_path / "docs" / "api-docs.md" + else: + if output_path.suffix == ".md": + md_file = output_path + else: + md_file = output_path / "api-docs.md" + + md_file.write_text(md_content) + click.echo(f"Generated Markdown documentation: {md_file}") + + if format in ["json", "all"]: + json_content = JSON_TEMPLATE.render( + spec=spec_data["spec"], + endpoints=spec_data["endpoints"], + schemas=spec_data["schemas"], + tags=spec_data["tags"], + timestamp=datetime.now().isoformat(), + ) + + if format == "all": + json_file = output_path / "docs" / "api-docs.json" + else: + if output_path.suffix == ".json": + json_file = output_path + else: + json_file = output_path / "api-docs.json" + + json_file.write_text(json_content) + click.echo(f"Generated JSON documentation: {json_file}") + + except ParseError as e: + click.echo(click.style("Error: ", fg="red") + f"Parse error: {e}") + sys.exit(1) + except Exception as e: + click.echo(click.style("Error: ", fg="red") + f"Unexpected error: {e}") + if verbose: + raise + sys.exit(1) -@main.command() -@click.argument('spec_file', type=click.Path(exists=True)) -@click.option('--json', is_flag=True, help='Output as JSON') -def validate(spec_file, json_output): +@main.command("validate") +@click.argument("spec_file", type=click.Path()) +@click.option("--json", "output_json", is_flag=True, help="Output as JSON") +@click.pass_context +def validate(ctx: click.Context, spec_file: str, output_json: bool): """Validate an OpenAPI specification file.""" - result = parse_openapi_spec(spec_file) - if result.get('valid'): - click.echo("✓ OpenAPI spec is valid") - if json_output: - import json - click.echo(json.dumps(result, indent=2)) - else: - click.echo("✗ OpenAPI spec is invalid") - if json_output: - import json - click.echo(json.dumps(result, indent=2)) + if ctx.obj is None: + ctx.obj = {} + verbose = ctx.obj.get("verbose", False) + + if not Path(spec_file).exists(): + click.echo(click.style("Error: ", fg="red") + f"Spec file not found: {spec_file}") + sys.exit(1) + + try: + parser = OpenAPIParser(spec_file) + is_valid, errors = parser.validate() + + if is_valid: + if output_json: + click.echo(json.dumps({ + "valid": True, + "message": "OpenAPI specification is valid", + "file": spec_file, + })) + else: + click.echo(click.style("✓ ", fg="green") + + f"OpenAPI specification is valid: {spec_file}") else: - for error in result.get('errors', []): - click.echo(f" - {error}") + if output_json: + click.echo(json.dumps({ + "valid": False, + "errors": errors, + "file": spec_file, + })) + else: + click.echo(click.style("✗ ", fg="red") + + f"Invalid OpenAPI specification: {spec_file}") + for error in errors: + click.echo(f" - {error}") + sys.exit(1) + + except ParseError as e: + if output_json: + click.echo(json.dumps({ + "valid": False, + "errors": [str(e)], + "file": spec_file, + })) + else: + click.echo(click.style("✗ ", fg="red") + + f"Failed to parse spec: {e}") + sys.exit(1) -@main.command() -@click.argument('spec_file', type=click.Path(exists=True)) -@click.argument('query', nargs=-1) -@click.option('--limit', '-l', default=10, help='Maximum results') -@click.option('--json', is_flag=True, help='Output as JSON') -def search(spec_file, query, limit, json_output): +@main.command("search") +@click.argument("spec_file", type=click.Path()) +@click.argument("query", nargs=-1) +@click.option("--limit", "-l", default=10, type=int, help="Maximum results") +@click.option("--json", "output_json", is_flag=True, help="Output as JSON") +@click.pass_context +def search(ctx: click.Context, spec_file: str, query: tuple, + limit: int, output_json: bool): """Search for endpoints in an OpenAPI specification.""" - search_term = ' '.join(query) - results = search_endpoints(spec_file, search_term, limit) - if json_output: - import json - click.echo(json.dumps(results, indent=2)) - else: - if not results: - click.echo("No results found.") - return - for result in results: - click.echo(f"\n{result['method']} {result['path']}") - click.echo(f" {result.get('summary', result.get('description', 'No description'))}") - click.echo(f" Tags: {', '.join(result.get('tags', []))}") + if ctx.obj is None: + ctx.obj = {} + verbose = ctx.obj.get("verbose", False) + + if not Path(spec_file).exists(): + click.echo(click.style("Error: ", fg="red") + f"Spec file not found: {spec_file}") + sys.exit(1) + + try: + parser = OpenAPIParser(spec_file) + spec_data = parser.parse_with_examples() + + search_index = SearchIndex() + search_index.build(spec_data["spec"]) + + search_query = " ".join(query) + results = search_index.search(search_query, limit=limit) + + if output_json: + output = { + "query": search_query, + "count": len(results), + "results": [ + { + "path": r.endpoint_path, + "method": r.endpoint_method, + "title": r.title, + "description": r.description, + "tags": r.tags, + "score": r.score, + } + for r in results + ], + } + click.echo(json.dumps(output, indent=2)) + else: + if not results: + click.echo(f"No results found for: {search_query}") + else: + click.echo(f"Found {len(results)} results for: {search_query}\n") + for i, result in enumerate(results, 1): + method_colors = { + "GET": "green", + "POST": "blue", + "PUT": "yellow", + "PATCH": "magenta", + "DELETE": "red", + } + color = method_colors.get(result.endpoint_method, "white") + click.echo( + f"{i}. " + f"{click.style(result.endpoint_method, fg=color, bold=True)} " + f"{result.endpoint_path}" + ) + if result.title: + click.echo(f" {result.title}") + click.echo(f" Score: {result.score:.2f}") + click.echo() + + except ParseError as e: + click.echo(click.style("Error: ", fg="red") + f"Parse error: {e}") + sys.exit(1) + except Exception as e: + click.echo(click.style("Error: ", fg="red") + f"Unexpected error: {e}") + if verbose: + raise + sys.exit(1) + + +if __name__ == "__main__": + main()