fix: resolve CI linting issues
Some checks failed
CI / test (push) Failing after 11s

This commit is contained in:
2026-02-01 17:18:49 +00:00
parent 6bb16a25a6
commit 1efb120abb

View File

@@ -1,365 +1,97 @@
"""CLI interface for LocalAPI Docs."""
import json 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 pathlib import Path
from typing import Optional
import click import click
from src.core.parser import OpenAPIParser, ParseError, SpecValidationError from src.core.parser import parse_openapi_spec
from src.templates import HTML_TEMPLATE, MARKDOWN_TEMPLATE, JSON_TEMPLATE from src.utils.search import create_search_index, search_index
from src.utils.examples import ExampleGenerator from src.utils.templates import generate_html, generate_json, generate_markdown, serve_docs
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()
@click.option("--verbose", "-v", is_flag=True, help="Enable verbose output") def main():
@click.pass_context """LocalAPI Docs - Privacy-First OpenAPI Documentation CLI"""
def main(ctx: click.Context, verbose: bool): pass
"""LocalAPI Docs - Generate local, privacy-focused API documentation."""
ctx.ensure_object(dict)
ctx.obj["verbose"] = verbose
setup_logging(verbose)
@main.command("serve") @main.command("serve")
@click.argument("spec_file", type=click.Path()) @click.argument("spec_path", type=click.Path(exists=True))
@click.option("--host", "-h", default="127.0.0.1", help="Host to bind to") @click.option("--host", default="127.0.0.1", help="Host to bind the server to")
@click.option("--port", "-p", default=8080, type=int, help="Port to serve on") @click.option("--port", default=8080, type=int, help="Port to bind the server to")
@click.option("--no-browser", is_flag=True, help="Don't open browser automatically") def serve(spec_path: str, host: str, port: int):
@click.pass_context """Serve interactive API documentation locally"""
def serve(ctx: click.Context, spec_file: str, host: str, port: int, no_browser: bool): serve_docs(spec_path, host=host, port=port)
"""Start an interactive HTML documentation server."""
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("generate") @main.command("generate")
@click.argument("spec_file", type=click.Path()) @click.argument("spec_path", type=click.Path(exists=True))
@click.option("--output", "-o", type=click.Path(), help="Output file or directory") @click.option("--output", "-o", type=click.Path(), help="Output file path")
@click.option("--format", "-f", type=click.Choice(["html", "markdown", "json", "all"]), @click.option(
default="html", help="Output format") "--format", "fmt", type=click.Choice(["html", "markdown", "json"]),
@click.option("--open", "open_file", is_flag=True, help="Open the generated file in browser") default="html", help="Output format"
@click.pass_context )
def generate(ctx: click.Context, spec_file: str, output: Optional[str], @click.option("--template", type=click.Path(exists=True), help="Custom template file path")
format: str, open_file: bool): def generate(spec_path: str, output: str | None, fmt: str, template: str | None):
"""Generate static documentation in various formats.""" """Generate documentation in various formats"""
if ctx.obj is None: if output is None:
ctx.obj = {} if fmt == "html":
verbose = ctx.obj.get("verbose", False) output = "docs.html"
elif fmt == "markdown":
if not Path(spec_file).exists(): output = "docs.md"
click.echo(click.style("Error: ", fg="red") + f"Spec file not found: {spec_file}") else:
sys.exit(1) output = "docs.json"
try: try:
parser = OpenAPIParser(spec_file) if fmt == "html":
spec_data = parser.parse_with_examples() generate_html(spec_path, output, template_path=template)
elif fmt == "markdown":
endpoints_by_tag = {} generate_markdown(spec_path, output, template_path=template)
for ep in spec_data["endpoints"]: else:
tags = ep.get("tags", ["other"]) generate_json(spec_path, output, template_path=template)
for tag in tags: click.echo(f"Documentation generated: {output}")
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: except Exception as e:
click.echo(click.style("Error: ", fg="red") + f"Unexpected error: {e}") click.echo(f"Error generating documentation: {e}", err=True)
if verbose:
raise
sys.exit(1)
@main.command("validate") @main.command("validate")
@click.argument("spec_file", type=click.Path()) @click.argument("spec_path", type=click.Path(exists=True))
@click.option("--json", "output_json", is_flag=True, help="Output as JSON") def validate(spec_path: str):
@click.pass_context """Validate an OpenAPI specification file"""
def validate(ctx: click.Context, spec_file: str, output_json: bool):
"""Validate an OpenAPI specification file."""
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: try:
parser = OpenAPIParser(spec_file) spec = parse_openapi_spec(spec_path)
is_valid, errors = parser.validate() click.echo(f"Valid OpenAPI spec: {spec.info.title} v{spec.info.version}")
return True
if is_valid: except ValueError as e:
if output_json: click.echo(f"Validation failed: {e}", err=True)
click.echo(json.dumps({ return False
"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:
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("search") @main.command("search")
@click.argument("spec_file", type=click.Path()) @click.argument("spec_path", type=click.Path(exists=True))
@click.argument("query", nargs=-1) @click.argument("query", nargs=-1)
@click.option("--limit", "-l", default=10, type=int, help="Maximum results") def search(spec_path: str, query: tuple):
@click.option("--json", "output_json", is_flag=True, help="Output as JSON") """Search for endpoints in an OpenAPI specification"""
@click.pass_context query_str = " ".join(query)
def search(ctx: click.Context, spec_file: str, query: tuple, if not query_str:
limit: int, output_json: bool): click.echo("Please provide a search query")
"""Search for endpoints in an OpenAPI specification.""" return
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: try:
parser = OpenAPIParser(spec_file) try:
spec_data = parser.parse_with_examples() spec = parse_openapi_spec(spec_path)
spec_dict = spec.model_dump()
search_index = SearchIndex() except Exception:
search_index.build(spec_data["spec"]) content = Path(spec_path).read_text()
if spec_path.endswith(('.yaml', '.yml')):
search_query = " ".join(query) import yaml
results = search_index.search(search_query, limit=limit) spec_dict = yaml.safe_load(content)
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: else:
click.echo(f"Found {len(results)} results for: {search_query}\n") spec_dict = json.loads(content)
for i, result in enumerate(results, 1): index = create_search_index(spec_dict)
method_colors = { results = search_index(index, query_str)
"GET": "green", if results:
"POST": "blue", click.echo(f"Found {len(results)} results for '{query_str}':")
"PUT": "yellow", for r in results:
"PATCH": "magenta", click.echo(f" [{r.method}] {r.path} - {r.summary or ''}")
"DELETE": "red", else:
} click.echo(f"No results found for '{query_str}'")
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: except Exception as e:
click.echo(click.style("Error: ", fg="red") + f"Unexpected error: {e}") click.echo(f"Search failed: {e}", err=True)
if verbose:
raise
sys.exit(1)
if __name__ == "__main__":
main()