Files
changedetection.io/docs/python-apidoc/apidoc.py
dgtlmoon 8379fdb1f8
Some checks failed
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built 📦 package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
Refactor API Documentation (#3383)
2025-08-23 19:28:34 +02:00

397 lines
18 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Python API Documentation Generator
Parses @api comments from Python files and generates Bootstrap HTML docs
"""
import re
import os
import json
import argparse
from pathlib import Path
from dataclasses import dataclass, field
from typing import List, Dict, Any
from jinja2 import Template
@dataclass
class ApiEndpoint:
method: str = ""
url: str = ""
title: str = ""
name: str = ""
group: str = "General"
group_order: int = 999 # Default to high number (low priority)
group_doc_order: int = 999 # Default to high number (low priority) for sidebar ordering
description: str = ""
params: List[Dict[str, Any]] = field(default_factory=list)
query: List[Dict[str, Any]] = field(default_factory=list)
success: List[Dict[str, Any]] = field(default_factory=list)
error: List[Dict[str, Any]] = field(default_factory=list)
example: str = ""
example_request: str = ""
example_response: str = ""
def prettify_json(text: str) -> str:
"""Attempt to prettify JSON content in the text"""
if not text or not text.strip():
return text
# First, try to parse the entire text as JSON
stripped_text = text.strip()
try:
json_obj = json.loads(stripped_text)
return json.dumps(json_obj, indent=2, ensure_ascii=False)
except (json.JSONDecodeError, ValueError):
pass
# If that fails, try to find JSON blocks within the text
lines = text.split('\n')
prettified_lines = []
i = 0
while i < len(lines):
line = lines[i]
stripped_line = line.strip()
# Look for the start of a JSON object or array
if stripped_line.startswith('{') or stripped_line.startswith('['):
# Try to collect a complete JSON block
json_lines = [stripped_line]
brace_count = stripped_line.count('{') - stripped_line.count('}')
bracket_count = stripped_line.count('[') - stripped_line.count(']')
j = i + 1
while j < len(lines) and (brace_count > 0 or bracket_count > 0):
next_line = lines[j].strip()
json_lines.append(next_line)
brace_count += next_line.count('{') - next_line.count('}')
bracket_count += next_line.count('[') - next_line.count(']')
j += 1
# Try to parse and prettify the collected JSON block
json_block = '\n'.join(json_lines)
try:
json_obj = json.loads(json_block)
prettified = json.dumps(json_obj, indent=2, ensure_ascii=False)
prettified_lines.append(prettified)
i = j # Skip the lines we just processed
continue
except (json.JSONDecodeError, ValueError):
# If parsing failed, just add the original line
prettified_lines.append(line)
else:
prettified_lines.append(line)
i += 1
return '\n'.join(prettified_lines)
class ApiDocParser:
def __init__(self):
self.patterns = {
'api': re.compile(r'@api\s*\{(\w+)\}\s*([^\s]+)\s*(.*)'),
'apiName': re.compile(r'@apiName\s+(.*)'),
'apiGroup': re.compile(r'@apiGroup\s+(.*)'),
'apiGroupOrder': re.compile(r'@apiGroupOrder\s+(\d+)'),
'apiGroupDocOrder': re.compile(r'@apiGroupDocOrder\s+(\d+)'),
'apiDescription': re.compile(r'@apiDescription\s+(.*)'),
'apiParam': re.compile(r'@apiParam\s*\{([^}]+)\}\s*(\[?[\w.:]+\]?)\s*(.*)'),
'apiQuery': re.compile(r'@apiQuery\s*\{([^}]+)\}\s*(\[?[\w.:]+\]?)\s*(.*)'),
'apiSuccess': re.compile(r'@apiSuccess\s*\((\d+)\)\s*\{([^}]+)\}\s*(\w+)?\s*(.*)'),
'apiError': re.compile(r'@apiError\s*\((\d+)\)\s*\{([^}]+)\}\s*(.*)'),
'apiExample': re.compile(r'@apiExample\s*\{([^}]+)\}\s*(.*)'),
'apiExampleRequest': re.compile(r'@apiExampleRequest\s*\{([^}]+)\}\s*(.*)'),
'apiExampleResponse': re.compile(r'@apiExampleResponse\s*\{([^}]+)\}\s*(.*)'),
}
def parse_file(self, file_path: Path) -> List[ApiEndpoint]:
"""Parse a single Python file for @api comments"""
try:
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
except Exception as e:
print(f"Error reading {file_path}: {e}")
return []
endpoints = []
current_endpoint = None
in_multiline_example = False
in_multiline_request = False
in_multiline_response = False
example_lines = []
request_lines = []
response_lines = []
for line in content.split('\n'):
line_stripped = line.strip()
# Handle multiline examples, requests, and responses
if in_multiline_example or in_multiline_request or in_multiline_response:
# Check if this line starts a new example type or exits multiline mode
should_exit_multiline = False
if line_stripped.startswith('@apiExampleRequest'):
# Finalize current multiline block and start request
should_exit_multiline = True
elif line_stripped.startswith('@apiExampleResponse'):
# Finalize current multiline block and start response
should_exit_multiline = True
elif line_stripped.startswith('@apiExample'):
# Finalize current multiline block and start example
should_exit_multiline = True
elif line_stripped.startswith('@api') and not any(x in line_stripped for x in ['@apiExample', '@apiExampleRequest', '@apiExampleResponse']):
# Exit multiline mode for any other @api directive
should_exit_multiline = True
if should_exit_multiline:
# Finalize any active multiline blocks
if in_multiline_example and current_endpoint and example_lines:
current_endpoint.example = '\n'.join(example_lines)
if in_multiline_request and current_endpoint and request_lines:
current_endpoint.example_request = '\n'.join(request_lines)
if in_multiline_response and current_endpoint and response_lines:
raw_response = '\n'.join(response_lines)
current_endpoint.example_response = prettify_json(raw_response)
# Reset all multiline states
in_multiline_example = False
in_multiline_request = False
in_multiline_response = False
example_lines = []
request_lines = []
response_lines = []
# If this is still an example directive, continue processing it
if not (line_stripped.startswith('@apiExample') or line_stripped.startswith('@apiExampleRequest') or line_stripped.startswith('@apiExampleResponse')):
# This is a different @api directive, let it be processed normally
pass
# If it's an example directive, it will be processed below
else:
# For multiline blocks, preserve the content more liberally
# Remove leading comment markers but preserve structure
clean_line = re.sub(r'^\s*[#*/]*\s?', '', line)
# Add the line if it has content or if it's an empty line (for formatting)
if clean_line or not line_stripped:
if in_multiline_example:
example_lines.append(clean_line)
elif in_multiline_request:
request_lines.append(clean_line)
elif in_multiline_response:
response_lines.append(clean_line)
continue
# Skip non-comment lines
if not any(marker in line_stripped for marker in ['@api', '#', '*', '//']):
continue
# Extract @api patterns
for pattern_name, pattern in self.patterns.items():
match = pattern.search(line_stripped)
if match:
if pattern_name == 'api':
# Start new endpoint
if current_endpoint:
endpoints.append(current_endpoint)
current_endpoint = ApiEndpoint()
current_endpoint.method = match.group(1).lower()
current_endpoint.url = match.group(2)
current_endpoint.title = match.group(3).strip()
elif current_endpoint:
if pattern_name == 'apiName':
current_endpoint.name = match.group(1)
elif pattern_name == 'apiGroup':
current_endpoint.group = match.group(1)
elif pattern_name == 'apiGroupOrder':
current_endpoint.group_order = int(match.group(1))
elif pattern_name == 'apiGroupDocOrder':
current_endpoint.group_doc_order = int(match.group(1))
elif pattern_name == 'apiDescription':
current_endpoint.description = match.group(1)
elif pattern_name == 'apiParam':
param_type = match.group(1)
param_name = match.group(2).strip('[]')
param_desc = match.group(3)
optional = '[' in match.group(2)
current_endpoint.params.append({
'type': param_type,
'name': param_name,
'description': param_desc,
'optional': optional
})
elif pattern_name == 'apiQuery':
param_type = match.group(1)
param_name = match.group(2).strip('[]')
param_desc = match.group(3)
optional = '[' in match.group(2)
current_endpoint.query.append({
'type': param_type,
'name': param_name,
'description': param_desc,
'optional': optional
})
elif pattern_name == 'apiSuccess':
status_code = match.group(1)
response_type = match.group(2)
response_name = match.group(3) or 'response'
response_desc = match.group(4)
current_endpoint.success.append({
'status': status_code,
'type': response_type,
'name': response_name,
'description': response_desc
})
elif pattern_name == 'apiError':
status_code = match.group(1)
error_type = match.group(2)
error_desc = match.group(3)
current_endpoint.error.append({
'status': status_code,
'type': error_type,
'description': error_desc
})
elif pattern_name == 'apiExample':
in_multiline_example = True
# Skip the "{curl} Example usage:" header line
example_lines = []
elif pattern_name == 'apiExampleRequest':
in_multiline_request = True
# Skip the "{curl} Request:" header line
request_lines = []
elif pattern_name == 'apiExampleResponse':
in_multiline_response = True
# Skip the "{json} Response:" header line
response_lines = []
break
# Don't forget the last endpoint
if current_endpoint:
if in_multiline_example and example_lines:
current_endpoint.example = '\n'.join(example_lines)
if in_multiline_request and request_lines:
current_endpoint.example_request = '\n'.join(request_lines)
if in_multiline_response and response_lines:
raw_response = '\n'.join(response_lines)
current_endpoint.example_response = prettify_json(raw_response)
endpoints.append(current_endpoint)
return endpoints
def parse_directory(self, directory: Path) -> List[ApiEndpoint]:
"""Parse all Python files in a directory"""
all_endpoints = []
for py_file in directory.rglob('*.py'):
endpoints = self.parse_file(py_file)
all_endpoints.extend(endpoints)
return all_endpoints
def generate_html(endpoints: List[ApiEndpoint], output_file: Path, template_file: Path):
"""Generate HTML documentation using Jinja2 template"""
# Group endpoints by group and collect group orders
grouped_endpoints = {}
group_orders = {}
group_doc_orders = {}
for endpoint in endpoints:
group = endpoint.group
if group not in grouped_endpoints:
grouped_endpoints[group] = []
group_orders[group] = endpoint.group_order
group_doc_orders[group] = endpoint.group_doc_order
grouped_endpoints[group].append(endpoint)
# Use the lowest order value for the group (in case of multiple definitions)
group_orders[group] = min(group_orders[group], endpoint.group_order)
group_doc_orders[group] = min(group_doc_orders[group], endpoint.group_doc_order)
# Sort groups by doc order for sidebar (0 = highest priority), then by content order, then alphabetically
sorted_groups = sorted(grouped_endpoints.items(), key=lambda x: (group_doc_orders[x[0]], group_orders[x[0]], x[0]))
# Convert back to ordered dict and sort endpoints within each group
grouped_endpoints = {}
for group, endpoints_list in sorted_groups:
endpoints_list.sort(key=lambda x: (x.name, x.url))
grouped_endpoints[group] = endpoints_list
# Load template
with open(template_file, 'r', encoding='utf-8') as f:
template_content = f.read()
# Load introduction content
introduction_file = template_file.parent / 'introduction.html'
introduction_content = ""
if introduction_file.exists():
with open(introduction_file, 'r', encoding='utf-8') as f:
introduction_content = f.read()
# Load sidebar header content
sidebar_header_file = template_file.parent / 'sidebar-header.html'
sidebar_header_content = "<h4>API Documentation</h4>" # Default fallback
if sidebar_header_file.exists():
with open(sidebar_header_file, 'r', encoding='utf-8') as f:
sidebar_header_content = f.read()
template = Template(template_content)
html_content = template.render(
grouped_endpoints=grouped_endpoints,
introduction_content=introduction_content,
sidebar_header_content=sidebar_header_content
)
with open(output_file, 'w', encoding='utf-8') as f:
f.write(html_content)
def main():
parser = argparse.ArgumentParser(description='Generate API documentation from Python source files')
parser.add_argument('-i', '--input', default='.',
help='Input directory to scan for Python files (default: current directory)')
parser.add_argument('-o', '--output', default='api_docs.html',
help='Output HTML file (default: api_docs.html)')
parser.add_argument('-t', '--template', default='template.html',
help='Template HTML file (default: template.html)')
args = parser.parse_args()
input_path = Path(args.input)
output_path = Path(args.output)
template_path = Path(args.template)
# Make template path relative to script location if not absolute
if not template_path.is_absolute():
template_path = Path(__file__).parent / template_path
if not input_path.exists():
print(f"Error: Input directory '{input_path}' does not exist")
return 1
if not template_path.exists():
print(f"Error: Template file '{template_path}' does not exist")
return 1
print(f"Scanning {input_path} for @api comments...")
doc_parser = ApiDocParser()
endpoints = doc_parser.parse_directory(input_path)
if not endpoints:
print("No API endpoints found!")
return 1
print(f"Found {len(endpoints)} API endpoints")
# Create output directory if needed
output_path.parent.mkdir(parents=True, exist_ok=True)
print(f"Generating HTML documentation to {output_path}...")
generate_html(endpoints, output_path, template_path)
print("Documentation generated successfully!")
print(f"Open {output_path.resolve()} in your browser to view the docs")
return 0
if __name__ == '__main__':
exit(main())