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
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))
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("✗ OpenAPI spec is invalid")
if json_output:
import json
click.echo(json.dumps(result, indent=2))
click.echo(click.style("", fg="green") +
f"OpenAPI specification is valid: {spec_file}")
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}")
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))
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("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', []))}")
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()