fix: resolve CI test failures
Some checks failed
CI / test (push) Has been cancelled

This commit is contained in:
2026-02-01 16:38:19 +00:00
parent c30f495048
commit 1bc1900d95

View File

@@ -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 import click
from .core.parser import parse_openapi_spec
from .core.generator import generate_docs from src.core.parser import OpenAPIParser, ParseError, SpecValidationError
from .core.models import APISpec, Endpoint, RequestExample, ResponseExample from src.templates import HTML_TEMPLATE, MARKDOWN_TEMPLATE, JSON_TEMPLATE
from .utils.search import search_endpoints 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() @click.group()
def main(): @click.option("--verbose", "-v", is_flag=True, help="Enable verbose output")
"""LocalAPI Docs - Generate local API documentation from OpenAPI specs.""" @click.pass_context
pass 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() @main.command("serve")
@click.argument('spec_file', type=click.Path(exists=True)) @click.argument("spec_file", type=click.Path())
@click.option('--host', '-h', default='127.0.0.1', help='Host to bind to') @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("--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.option("--no-browser", is_flag=True, help="Don't open browser automatically")
def serve(spec_file, host, port, no_browser): @click.pass_context
def serve(ctx: click.Context, spec_file: str, host: str, port: int, no_browser: bool):
"""Start an interactive HTML documentation server.""" """Start an interactive HTML documentation server."""
from .templates.html_template import generate_html_server if ctx.obj is None:
generate_html_server(spec_file, host, port, not no_browser) 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() @main.command("generate")
@click.argument('spec_file', type=click.Path(exists=True)) @click.argument("spec_file", type=click.Path())
@click.option('--output', '-o', help='Output file or directory') @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("--format", "-f", type=click.Choice(["html", "markdown", "json", "all"]),
@click.option('--open', is_flag=True, help='Open the generated file in browser') default="html", help="Output format")
def generate(spec_file, output, format, open): @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 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() @main.command("validate")
@click.argument('spec_file', type=click.Path(exists=True)) @click.argument("spec_file", type=click.Path())
@click.option('--json', is_flag=True, help='Output as JSON') @click.option("--json", "output_json", is_flag=True, help="Output as JSON")
def validate(spec_file, json_output): @click.pass_context
def validate(ctx: click.Context, spec_file: str, output_json: bool):
"""Validate an OpenAPI specification file.""" """Validate an OpenAPI specification file."""
result = parse_openapi_spec(spec_file) if ctx.obj is None:
if result.get('valid'): ctx.obj = {}
click.echo("✓ OpenAPI spec is valid") verbose = ctx.obj.get("verbose", False)
if json_output:
import json if not Path(spec_file).exists():
click.echo(json.dumps(result, indent=2)) 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: else:
click.echo("✗ OpenAPI spec is invalid") click.echo(click.style("", fg="green") +
if json_output: f"OpenAPI specification is valid: {spec_file}")
import json
click.echo(json.dumps(result, indent=2))
else: else:
for error in result.get('errors', []): 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}") 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() @main.command("search")
@click.argument('spec_file', type=click.Path(exists=True)) @click.argument("spec_file", type=click.Path())
@click.argument('query', nargs=-1) @click.argument("query", nargs=-1)
@click.option('--limit', '-l', default=10, help='Maximum results') @click.option("--limit", "-l", default=10, type=int, help="Maximum results")
@click.option('--json', is_flag=True, help='Output as JSON') @click.option("--json", "output_json", is_flag=True, help="Output as JSON")
def search(spec_file, query, limit, json_output): @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 for endpoints in an OpenAPI specification."""
search_term = ' '.join(query) if ctx.obj is None:
results = search_endpoints(spec_file, search_term, limit) ctx.obj = {}
if json_output: verbose = ctx.obj.get("verbose", False)
import json
click.echo(json.dumps(results, indent=2)) 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: else:
if not results: if not results:
click.echo("No results found.") click.echo(f"No results found for: {search_query}")
return else:
for result in results: click.echo(f"Found {len(results)} results for: {search_query}\n")
click.echo(f"\n{result['method']} {result['path']}") for i, result in enumerate(results, 1):
click.echo(f" {result.get('summary', result.get('description', 'No description'))}") method_colors = {
click.echo(f" Tags: {', '.join(result.get('tags', []))}") "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()