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 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 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])
from src.core.parser import parse_openapi_spec
from src.utils.search import create_search_index, search_index
from src.utils.templates import generate_html, generate_json, generate_markdown, serve_docs
@click.group()
@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)
def main():
"""LocalAPI Docs - Privacy-First OpenAPI Documentation CLI"""
pass
@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."""
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)
@click.argument("spec_path", type=click.Path(exists=True))
@click.option("--host", default="127.0.0.1", help="Host to bind the server to")
@click.option("--port", default=8080, type=int, help="Port to bind the server to")
def serve(spec_path: str, host: str, port: int):
"""Serve interactive API documentation locally"""
serve_docs(spec_path, host=host, port=port)
@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."""
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)
@click.argument("spec_path", type=click.Path(exists=True))
@click.option("--output", "-o", type=click.Path(), help="Output file path")
@click.option(
"--format", "fmt", type=click.Choice(["html", "markdown", "json"]),
default="html", help="Output format"
)
@click.option("--template", type=click.Path(exists=True), help="Custom template file path")
def generate(spec_path: str, output: str | None, fmt: str, template: str | None):
"""Generate documentation in various formats"""
if output is None:
if fmt == "html":
output = "docs.html"
elif fmt == "markdown":
output = "docs.md"
else:
output = "docs.json"
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)
if fmt == "html":
generate_html(spec_path, output, template_path=template)
elif fmt == "markdown":
generate_markdown(spec_path, output, template_path=template)
else:
generate_json(spec_path, output, template_path=template)
click.echo(f"Documentation generated: {output}")
except Exception as e:
click.echo(click.style("Error: ", fg="red") + f"Unexpected error: {e}")
if verbose:
raise
sys.exit(1)
click.echo(f"Error generating documentation: {e}", err=True)
@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."""
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)
@click.argument("spec_path", type=click.Path(exists=True))
def validate(spec_path: str):
"""Validate an OpenAPI specification file"""
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:
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)
spec = parse_openapi_spec(spec_path)
click.echo(f"Valid OpenAPI spec: {spec.info.title} v{spec.info.version}")
return True
except ValueError as e:
click.echo(f"Validation failed: {e}", err=True)
return False
@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.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."""
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)
def search(spec_path: str, query: tuple):
"""Search for endpoints in an OpenAPI specification"""
query_str = " ".join(query)
if not query_str:
click.echo("Please provide a search query")
return
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}")
try:
spec = parse_openapi_spec(spec_path)
spec_dict = spec.model_dump()
except Exception:
content = Path(spec_path).read_text()
if spec_path.endswith(('.yaml', '.yml')):
import yaml
spec_dict = yaml.safe_load(content)
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)
spec_dict = json.loads(content)
index = create_search_index(spec_dict)
results = search_index(index, query_str)
if results:
click.echo(f"Found {len(results)} results for '{query_str}':")
for r in results:
click.echo(f" [{r.method}] {r.path} - {r.summary or ''}")
else:
click.echo(f"No results found for '{query_str}'")
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()
click.echo(f"Search failed: {e}", err=True)