1183 lines
38 KiB
Python
1183 lines
38 KiB
Python
"""Generate a psychotic static API documentation site for conduit-client."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
import html
|
|
import json
|
|
import re
|
|
import sys
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
SRC = ROOT / "src"
|
|
sys.path.insert(0, str(SRC))
|
|
|
|
METHOD_RE = re.compile(r"^(GET|POST|PUT|PATCH|DELETE)\s+(/[^-]+)\s*-\s*(.*)$", re.IGNORECASE)
|
|
HTTP_RE = re.compile(r"^(GET|POST|PUT|PATCH|DELETE)\s+(.+?)(?:\s+-\s*|\s+—\s*|\n)(.*)$", re.DOTALL | re.IGNORECASE)
|
|
|
|
|
|
@dataclass
|
|
class ArgInfo:
|
|
name: str
|
|
annotation: str | None
|
|
default: str | None
|
|
is_keyword_only: bool = False
|
|
|
|
|
|
@dataclass
|
|
class MethodInfo:
|
|
name: str
|
|
http_method: str
|
|
endpoint: str
|
|
description: str
|
|
args: list[ArgInfo]
|
|
request_model: str | None
|
|
response_model: str | None
|
|
return_annotation: str | None
|
|
module_name: str
|
|
class_name: str
|
|
is_async: bool = False
|
|
docstring: str = ""
|
|
|
|
|
|
@dataclass
|
|
class NamespaceInfo:
|
|
name: str
|
|
title: str
|
|
description: str
|
|
sync_class: str
|
|
async_class: str
|
|
methods: list[MethodInfo] = field(default_factory=list)
|
|
|
|
|
|
def parse_docstring(node: ast.AsyncFunctionDef | ast.FunctionDef) -> str:
|
|
doc = ast.get_docstring(node)
|
|
return doc or ""
|
|
|
|
|
|
def extract_http_info(doc: str) -> tuple[str, str, str]:
|
|
if not doc:
|
|
return "?", "?", ""
|
|
# Try the common "METHOD /path - description" format.
|
|
m = HTTP_RE.match(doc.strip())
|
|
if m:
|
|
method, endpoint, desc = m.groups()
|
|
return method.upper(), endpoint.strip(), desc.strip()
|
|
# Fallback: first sentence.
|
|
first = doc.strip().split("\n")[0]
|
|
return "?", "?", first
|
|
|
|
|
|
def annotation_str(node: ast.AST | None) -> str | None:
|
|
if node is None:
|
|
return None
|
|
if isinstance(node, ast.Constant):
|
|
return repr(node.value)
|
|
if isinstance(node, ast.Name):
|
|
return node.id
|
|
if isinstance(node, ast.Attribute):
|
|
parts = []
|
|
n: ast.AST = node
|
|
while isinstance(n, ast.Attribute):
|
|
parts.append(n.attr)
|
|
n = n.value
|
|
if isinstance(n, ast.Name):
|
|
parts.append(n.id)
|
|
return ".".join(reversed(parts))
|
|
if isinstance(node, ast.Subscript):
|
|
value = annotation_str(node.value)
|
|
slice_node = node.slice
|
|
if isinstance(slice_node, ast.Tuple):
|
|
slices = ", ".join(annotation_str(s) or "" for s in slice_node.elts)
|
|
return f"{value}[{slices}]"
|
|
sl = annotation_str(slice_node)
|
|
return f"{value}[{sl}]" if sl else value
|
|
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr):
|
|
left = annotation_str(node.left)
|
|
right = annotation_str(node.right)
|
|
return f"{left} | {right}"
|
|
if isinstance(node, ast.List):
|
|
return "[" + ", ".join(annotation_str(e) or "" for e in node.elts) + "]"
|
|
return ast.unparse(node)
|
|
|
|
|
|
def default_str(node: ast.expr | None) -> str | None:
|
|
if node is None:
|
|
return None
|
|
if isinstance(node, ast.Constant):
|
|
if node.value is None:
|
|
return "None"
|
|
return repr(node.value)
|
|
if isinstance(node, ast.NameConstant): # py3.8 compat
|
|
return repr(node.value)
|
|
if isinstance(node, ast.Name):
|
|
return node.id
|
|
return ast.unparse(node)
|
|
|
|
|
|
def extract_args(args_node: ast.arguments) -> list[ArgInfo]:
|
|
out: list[ArgInfo] = []
|
|
defaults = [None] * (len(args_node.args) - len(args_node.defaults)) + [
|
|
default_str(d) for d in args_node.defaults
|
|
]
|
|
for arg, default in zip(args_node.args, defaults, strict=True):
|
|
if arg.arg in ("self", "cls"):
|
|
continue
|
|
out.append(
|
|
ArgInfo(
|
|
name=arg.arg,
|
|
annotation=annotation_str(arg.annotation),
|
|
default=default,
|
|
)
|
|
)
|
|
# keyword-only
|
|
kw_defaults = [None] * (len(args_node.kwonlyargs) - len(args_node.kw_defaults)) + [
|
|
default_str(d) for d in args_node.kw_defaults
|
|
]
|
|
for arg, default in zip(args_node.kwonlyargs, kw_defaults, strict=True):
|
|
out.append(
|
|
ArgInfo(
|
|
name=arg.arg,
|
|
annotation=annotation_str(arg.annotation),
|
|
default=default,
|
|
is_keyword_only=True,
|
|
)
|
|
)
|
|
return out
|
|
|
|
|
|
def _model_name_from_expr(expr: ast.expr) -> str | None:
|
|
"""Extract a model identifier from a keyword value (Name or Call)."""
|
|
if isinstance(expr, ast.Name):
|
|
return expr.id
|
|
if isinstance(expr, ast.Call):
|
|
func = expr.func
|
|
if isinstance(func, ast.Attribute) and func.attr == "model_dump":
|
|
return annotation_str(func.value)
|
|
if isinstance(func, ast.Name):
|
|
return func.id
|
|
return None
|
|
|
|
|
|
def find_model_in_call(body: list[ast.stmt], attr: str) -> str | None:
|
|
"""Look for self._transport.request(..., json=MODEL.model_dump(...), response_model=MODEL)."""
|
|
for stmt in body:
|
|
for node in ast.walk(stmt):
|
|
if not isinstance(node, ast.Call):
|
|
continue
|
|
for kw in node.keywords:
|
|
if kw.arg == attr:
|
|
model = _model_name_from_expr(kw.value)
|
|
if model:
|
|
return model
|
|
return None
|
|
|
|
|
|
def find_response_model(body: list[ast.stmt]) -> str | None:
|
|
return find_model_in_call(body, "response_model")
|
|
|
|
|
|
def find_request_model(body: list[ast.stmt]) -> str | None:
|
|
return find_model_in_call(body, "json")
|
|
|
|
|
|
def get_request_model_from_args(args: list[ArgInfo], module_name: str) -> tuple[str | None, str | None]:
|
|
"""Return the request model name and the argument name that carries it."""
|
|
for arg in args:
|
|
if not arg.annotation:
|
|
continue
|
|
ann = arg.annotation
|
|
# strip list[...]
|
|
base = ann
|
|
if base.startswith("list["):
|
|
continue
|
|
if " | " in base:
|
|
base = base.split(" | ")[0]
|
|
if base in ("str", "int", "float", "bool", "dict", "Any", "None"):
|
|
continue
|
|
# Heuristic: if the arg name is request/settings/query/body/job, treat as model
|
|
if arg.name in ("request", "settings", "query", "body", "job"):
|
|
return base, arg.name
|
|
return None, None
|
|
|
|
|
|
def extract_methods(module_path: Path, sync_class_name: str, async_class_name: str) -> tuple[list[MethodInfo], list[MethodInfo]]:
|
|
tree = ast.parse(module_path.read_text(encoding="utf-8"), filename=str(module_path))
|
|
sync_methods: list[MethodInfo] = []
|
|
async_methods: list[MethodInfo] = []
|
|
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.ClassDef):
|
|
is_async = node.name == async_class_name
|
|
is_sync = node.name == sync_class_name
|
|
if not (is_async or is_sync):
|
|
continue
|
|
for item in node.body:
|
|
if not isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
continue
|
|
if item.name.startswith("_"):
|
|
continue
|
|
doc = parse_docstring(item)
|
|
http_method, endpoint, desc = extract_http_info(doc)
|
|
args = extract_args(item.args)
|
|
req_model, req_arg_name = get_request_model_from_args(args, module_path.stem)
|
|
if req_model is None:
|
|
req_model = find_request_model(item.body)
|
|
resp_model = find_response_model(item.body)
|
|
return_ann = annotation_str(item.returns)
|
|
info = MethodInfo(
|
|
name=item.name,
|
|
http_method=http_method,
|
|
endpoint=endpoint,
|
|
description=desc,
|
|
args=args,
|
|
request_model=req_model,
|
|
response_model=resp_model,
|
|
return_annotation=return_ann,
|
|
module_name=module_path.stem,
|
|
class_name=node.name,
|
|
is_async=is_async,
|
|
docstring=doc,
|
|
)
|
|
if is_async:
|
|
async_methods.append(info)
|
|
else:
|
|
sync_methods.append(info)
|
|
return sync_methods, async_methods
|
|
|
|
|
|
@dataclass
|
|
class FieldSample:
|
|
name: str
|
|
value: Any
|
|
annotation: str | None
|
|
required: bool
|
|
|
|
|
|
def resolve_model(model_name: str, module_name: str) -> Any | None:
|
|
"""Import a model class by name."""
|
|
# Map: GatewayClient -> gateway models; DnsClient -> dns models, etc.
|
|
parts = model_name.split(".")
|
|
if len(parts) == 1:
|
|
try:
|
|
module = __import__(f"conduit_client.models.{module_name}", fromlist=[parts[0]])
|
|
return getattr(module, parts[0], None)
|
|
except Exception:
|
|
try:
|
|
module = __import__("conduit_client.models.common", fromlist=[parts[0]])
|
|
return getattr(module, parts[0], None)
|
|
except Exception:
|
|
return None
|
|
else:
|
|
try:
|
|
module = __import__("conduit_client.models." + ".".join(parts[:-1]), fromlist=[parts[-1]])
|
|
return getattr(module, parts[-1], None)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def infer_value_for_field(name: str, field_info: Any, annotation: str | None) -> tuple[Any, bool]:
|
|
"""Return (sample_value, required)."""
|
|
required = field_info.is_required()
|
|
# Special-case obvious identifiers.
|
|
lowered = name.lower()
|
|
if "api_key" in lowered or "token" in lowered or "secret" in lowered or "password" in lowered:
|
|
return "YOUR_VALUE", required
|
|
if lowered == "did":
|
|
return "5550100", required
|
|
if lowered == "dst":
|
|
return "5550200", required
|
|
if lowered in ("url", "media1", "media2", "media3"):
|
|
return "https://example.com/resource", required
|
|
if lowered in ("id", "message_id", "job_id", "sub_id", "torrent_id", "vmid", "item_id"):
|
|
return 1, required
|
|
if lowered == "zone":
|
|
return "example.com", required
|
|
if lowered == "node":
|
|
return "pve", required
|
|
if lowered == "storage":
|
|
return "local-lvm", required
|
|
if lowered in ("username", "user"):
|
|
return "user", required
|
|
if lowered == "password":
|
|
return "pw", required
|
|
|
|
ann = annotation or ""
|
|
if "str" in ann and "int" not in ann:
|
|
if "list" in ann:
|
|
return ["value"], required
|
|
return "value", required
|
|
if "int" in ann and "str" not in ann:
|
|
if "list" in ann:
|
|
return [1], required
|
|
return 1, required
|
|
if "bool" in ann:
|
|
return True, required
|
|
if "list" in ann or "List" in ann:
|
|
return [], required
|
|
if "dict" in ann or "Dict" in ann:
|
|
return {}, required
|
|
if "float" in ann:
|
|
return 1.0, required
|
|
|
|
# Pydantic field metadata.
|
|
try:
|
|
examples = field_info.examples
|
|
if examples:
|
|
return examples[0], required
|
|
except Exception:
|
|
pass
|
|
try:
|
|
if field_info.default is not None and field_info.default is not ...:
|
|
return field_info.default, False
|
|
except Exception:
|
|
pass
|
|
try:
|
|
if field_info.default_factory is not None:
|
|
return field_info.default_factory(), False
|
|
except Exception:
|
|
pass
|
|
|
|
return "value", required
|
|
|
|
|
|
def model_samples(model_cls: Any) -> list[FieldSample]:
|
|
if model_cls is None or not hasattr(model_cls, "model_fields"):
|
|
return []
|
|
samples: list[FieldSample] = []
|
|
for name, field_info in model_cls.model_fields.items():
|
|
ann = annotation_str_from_any(field_info.annotation)
|
|
value, required = infer_value_for_field(name, field_info, ann)
|
|
samples.append(FieldSample(name=name, value=value, annotation=ann, required=required))
|
|
return samples
|
|
|
|
|
|
def annotation_str_from_any(obj: Any) -> str | None:
|
|
if obj is None:
|
|
return None
|
|
if isinstance(obj, type):
|
|
return obj.__name__
|
|
if hasattr(obj, "__origin__"):
|
|
origin = getattr(obj, "__origin__", None)
|
|
args = getattr(obj, "__args__", ())
|
|
if origin is list or origin is set:
|
|
inner = annotation_str_from_any(args[0]) if args else None
|
|
return f"list[{inner}]" if inner else "list"
|
|
if origin is dict:
|
|
k = annotation_str_from_any(args[0]) if args else None
|
|
v = annotation_str_from_any(args[1]) if len(args) > 1 else None
|
|
return f"dict[{k}, {v}]" if k and v else "dict"
|
|
if origin is type or origin is Any:
|
|
return "Any"
|
|
return str(obj).replace("typing.", "").replace("<class ", "").replace(">", "")
|
|
|
|
|
|
def render_value(value: Any, indent: int = 0) -> str:
|
|
if isinstance(value, str):
|
|
return repr(value)
|
|
if isinstance(value, (list, tuple)):
|
|
if not value:
|
|
return "[]"
|
|
inner = ",\n".join(" " * (indent + 4) + render_value(v, indent + 4) for v in value)
|
|
return "[\n" + inner + "\n" + " " * indent + "]"
|
|
if isinstance(value, dict):
|
|
if not value:
|
|
return "{}"
|
|
items = []
|
|
for k, v in value.items():
|
|
items.append(" " * (indent + 4) + repr(k) + ": " + render_value(v, indent + 4))
|
|
return "{\n" + ",\n".join(items) + "\n" + " " * indent + "}"
|
|
return repr(value)
|
|
|
|
|
|
def build_call_args(method: MethodInfo, request_model_cls: Any | None, req_arg_name: str | None) -> tuple[str, set[str]]:
|
|
"""Build a complete call-arguments snippet. Returns (snippet, used_arg_names)."""
|
|
used: set[str] = set()
|
|
pieces: list[str] = []
|
|
|
|
# Positional-like args first (as kwargs for clarity), except the request arg.
|
|
for arg in method.args:
|
|
if arg.name == req_arg_name:
|
|
continue
|
|
val = placeholder_for_arg(arg)
|
|
pieces.append(f"{arg.name}={val}")
|
|
used.add(arg.name)
|
|
|
|
if request_model_cls and req_arg_name:
|
|
samples = model_samples(request_model_cls)
|
|
required = [s for s in samples if s.required]
|
|
optional_included = [s for s in samples if not s.required][:1] # include one optional demo
|
|
included = required + optional_included
|
|
if included:
|
|
lines = [f"{req_arg_name}={request_model_cls.__name__}("]
|
|
for s in included:
|
|
lines.append(f" {s.name}={render_value(s.value)},")
|
|
lines.append(")")
|
|
pieces.append("\n".join(lines))
|
|
else:
|
|
pieces.append(f"{req_arg_name}={request_model_cls.__name__}()")
|
|
used.add(req_arg_name)
|
|
|
|
if not pieces:
|
|
return "", used
|
|
|
|
# Single-line if everything is short; multi-line if request model present.
|
|
if len(pieces) == 1 and "\n" not in pieces[0]:
|
|
return pieces[0], used
|
|
return ",\n".join(pieces), used
|
|
|
|
|
|
def placeholder_for_arg(arg: ArgInfo) -> str:
|
|
ann = arg.annotation or ""
|
|
name = arg.name.lower()
|
|
if arg.default is not None and arg.default != "None":
|
|
return arg.default
|
|
if "list" in ann:
|
|
return "[]"
|
|
if "dict" in ann:
|
|
return "{}"
|
|
if "bool" in ann:
|
|
return "True"
|
|
if "int" in ann and "str" not in ann:
|
|
return "1"
|
|
if "id" in name or name in ("vmid", "did", "dst"):
|
|
return '"value"'
|
|
return '"value"'
|
|
|
|
|
|
def generate_example(method: MethodInfo, async_client: bool) -> str:
|
|
"""Generate a complete Python example for one method."""
|
|
sync_async = "Async" if async_client else ""
|
|
await_kw = "await " if async_client else ""
|
|
ctx = "async with" if async_client else "with"
|
|
|
|
# Imports.
|
|
lines: list[str] = []
|
|
lines.append(f"from conduit_client import {sync_async}ConduitClient")
|
|
|
|
model_imports: list[str] = []
|
|
request_model_cls = None
|
|
req_arg_name = None
|
|
if method.request_model:
|
|
request_model_cls = resolve_model(method.request_model, method.module_name)
|
|
if request_model_cls is None:
|
|
request_model_cls = resolve_model(method.request_model, "common")
|
|
if request_model_cls is not None:
|
|
model_imports.append(request_model_cls.__name__)
|
|
|
|
if model_imports:
|
|
# Determine import path.
|
|
model_module = method.module_name
|
|
if request_model_cls is not None:
|
|
model_module = request_model_cls.__module__.replace("conduit_client.models.", "")
|
|
lines.append(f"from conduit_client.models.{model_module} import {', '.join(model_imports)}")
|
|
|
|
if async_client:
|
|
lines.append("import asyncio")
|
|
|
|
lines.append("")
|
|
|
|
# Build call arguments.
|
|
req_arg_name = None
|
|
if "request" in [a.name for a in method.args]:
|
|
req_arg_name = "request"
|
|
elif "settings" in [a.name for a in method.args]:
|
|
req_arg_name = "settings"
|
|
elif "query" in [a.name for a in method.args]:
|
|
req_arg_name = "query"
|
|
elif "body" in [a.name for a in method.args]:
|
|
req_arg_name = "body"
|
|
elif "job" in [a.name for a in method.args]:
|
|
req_arg_name = "job"
|
|
call_args, used = build_call_args(method, request_model_cls, req_arg_name)
|
|
|
|
# If request model wasn't detected by arg name, fall back to first model-looking arg.
|
|
if method.request_model and req_arg_name is None:
|
|
for arg in method.args:
|
|
if arg.annotation and arg.annotation not in ("str", "int", "float", "bool", "dict", "Any") and not arg.annotation.startswith("list["):
|
|
call_args, used = build_call_args(method, request_model_cls, arg.name)
|
|
break
|
|
|
|
# Method call.
|
|
client_var = "client"
|
|
|
|
def format_call(base_indent: int, prefix: str) -> list[str]:
|
|
if not call_args:
|
|
return [f"{' ' * base_indent}{prefix}{client_var}.{method.module_name}.{method.name}()"]
|
|
if "\n" not in call_args:
|
|
return [f"{' ' * base_indent}{prefix}{client_var}.{method.module_name}.{method.name}({call_args})"]
|
|
inner_indent = " " * (base_indent + 4)
|
|
call_lines = [f"{' ' * base_indent}{prefix}{client_var}.{method.module_name}.{method.name}("]
|
|
for raw in call_args.splitlines():
|
|
call_lines.append(inner_indent + raw)
|
|
call_lines.append(f"{' ' * base_indent})")
|
|
return call_lines
|
|
|
|
example_lines: list[str] = []
|
|
if async_client:
|
|
example_lines.append("async def main():")
|
|
example_lines.append(f" {ctx} {sync_async}ConduitClient(\"mega_sk_live_<prefix>_<secret>\") as {client_var}:")
|
|
example_lines.extend(format_call(8, f"result = {await_kw}"))
|
|
example_lines.append(" print(result)")
|
|
example_lines.append("")
|
|
example_lines.append("asyncio.run(main())")
|
|
else:
|
|
example_lines.append(f"{ctx} {sync_async}ConduitClient(\"mega_sk_live_<prefix>_<secret>\") as {client_var}:")
|
|
example_lines.extend(format_call(4, "result = "))
|
|
example_lines.append(" print(result)")
|
|
|
|
return "\n".join(lines + example_lines)
|
|
|
|
|
|
def signature_line(method: MethodInfo) -> str:
|
|
"""Render a Python-like signature string."""
|
|
parts: list[str] = []
|
|
for arg in method.args:
|
|
chunk = arg.name
|
|
if arg.annotation:
|
|
chunk += f": {arg.annotation}"
|
|
if arg.default is not None:
|
|
chunk += f" = {arg.default}"
|
|
parts.append(chunk)
|
|
sig = ", ".join(parts)
|
|
ret = method.response_model or method.return_annotation or "None"
|
|
return f"{method.name}({sig}) -> {ret}"
|
|
|
|
|
|
def build_namespaces() -> list[NamespaceInfo]:
|
|
namespaces: list[NamespaceInfo] = []
|
|
client_dir = SRC / "conduit_client" / "clients"
|
|
# Order matches the main client attribute order.
|
|
order = ["gateway", "sms", "media_ingest", "media_library", "torrents", "wiki", "compute", "dns"]
|
|
modules = sorted(client_dir.glob("*.py"), key=lambda p: order.index(p.stem) if p.stem in order else 99)
|
|
|
|
for module_path in modules:
|
|
if module_path.name.startswith("_"):
|
|
continue
|
|
tree = ast.parse(module_path.read_text(encoding="utf-8"), filename=str(module_path))
|
|
sync_class = None
|
|
async_class = None
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.ClassDef):
|
|
if node.name.startswith("Async"):
|
|
async_class = node.name
|
|
elif node.name.endswith("Client"):
|
|
sync_class = node.name
|
|
if sync_class is None:
|
|
continue
|
|
if async_class is None:
|
|
async_class = "Async" + sync_class
|
|
sync_methods, async_methods = extract_methods(module_path, sync_class, async_class)
|
|
|
|
# Pair sync and async methods by name.
|
|
methods_by_name: dict[str, tuple[MethodInfo | None, MethodInfo | None]] = {}
|
|
for m in sync_methods:
|
|
methods_by_name.setdefault(m.name, (None, None))
|
|
methods_by_name[m.name] = (m, methods_by_name[m.name][1])
|
|
for m in async_methods:
|
|
methods_by_name.setdefault(m.name, (None, None))
|
|
methods_by_name[m.name] = (methods_by_name[m.name][0], m)
|
|
|
|
# Use sync method as canonical, fallback to async.
|
|
paired_methods: list[MethodInfo] = []
|
|
for name in sorted(methods_by_name):
|
|
sync_m, async_m = methods_by_name[name]
|
|
canonical = sync_m or async_m
|
|
if canonical is None:
|
|
continue
|
|
paired_methods.append(canonical)
|
|
|
|
module_doc = ast.get_docstring(tree) or ""
|
|
title = sync_class.replace("Client", "")
|
|
ns = NamespaceInfo(
|
|
name=module_path.stem,
|
|
title=title,
|
|
description=module_doc,
|
|
sync_class=sync_class,
|
|
async_class=async_class,
|
|
methods=paired_methods,
|
|
)
|
|
namespaces.append(ns)
|
|
return namespaces
|
|
|
|
|
|
def escape_js(s: str) -> str:
|
|
return json.dumps(s)
|
|
|
|
|
|
def render_html(namespaces: list[NamespaceInfo]) -> str:
|
|
total_methods = sum(len(ns.methods) for ns in namespaces)
|
|
|
|
nav_items: list[str] = []
|
|
cards: list[str] = []
|
|
|
|
for ns in namespaces:
|
|
method_links: list[str] = []
|
|
for method in ns.methods:
|
|
anchor = f"{ns.name}-{method.name}"
|
|
method_links.append(
|
|
f'<a class="method-link" href="#{anchor}" data-ns="{html.escape(ns.name)}" data-method="{html.escape(method.name)}">'
|
|
f'<span class="method-dot {method.http_method.lower()}"></span>'
|
|
f'{html.escape(method.name)}'
|
|
f'</a>'
|
|
)
|
|
|
|
nav_items.append(
|
|
f'<div class="ns-group">'
|
|
f'<button class="ns-toggle" aria-expanded="false" data-ns="{html.escape(ns.name)}">'
|
|
f'<span class="ns-chevron">▸</span>'
|
|
f'<span class="ns-name">{html.escape(ns.name)}</span>'
|
|
f'<span class="ns-count">{len(ns.methods)}</span>'
|
|
f'</button>'
|
|
f'<div class="ns-methods" id="nav-{html.escape(ns.name)}">'
|
|
f'{"".join(method_links)}'
|
|
f'</div>'
|
|
f'</div>'
|
|
)
|
|
|
|
cards.append(
|
|
f'<section class="namespace-section" id="ns-{html.escape(ns.name)}">'
|
|
f'<header class="ns-header">'
|
|
f'<h2 class="ns-title">{html.escape(ns.name)}</h2>'
|
|
f'<p class="ns-desc">{html.escape(ns.description or f"Methods for the {ns.title} façade.")}</p>'
|
|
f'</header>'
|
|
)
|
|
|
|
for method in ns.methods:
|
|
anchor = f"{ns.name}-{method.name}"
|
|
sync_ex = generate_example(method, async_client=False)
|
|
async_ex = generate_example(method, async_client=True)
|
|
sig = signature_line(method)
|
|
cards.append(
|
|
f'<article class="method-card" id="{html.escape(anchor)}">'
|
|
f'<div class="method-header">'
|
|
f'<h3 class="method-name">{html.escape(method.name)}</h3>'
|
|
f'<span class="http-badge {method.http_method.lower()}">{html.escape(method.http_method)}</span>'
|
|
f'</div>'
|
|
f'<p class="method-endpoint">{html.escape(method.endpoint)}</p>'
|
|
f'<p class="method-desc">{html.escape(method.description)}</p>'
|
|
f'<pre class="signature"><code>{html.escape(sig)}</code></pre>'
|
|
f'<div class="example-tabs">'
|
|
f'<button class="tab-btn active" data-target="ex-sync-{anchor}">sync</button>'
|
|
f'<button class="tab-btn" data-target="ex-async-{anchor}">async</button>'
|
|
f'</div>'
|
|
f'<div class="example-wrap">'
|
|
f'<div class="example active" id="ex-sync-{anchor}">'
|
|
f'<button class="copy-btn" data-code={escape_js(sync_ex)}>copy</button>'
|
|
f'<pre><code class="language-python">{html.escape(sync_ex)}</code></pre>'
|
|
f'</div>'
|
|
f'<div class="example" id="ex-async-{anchor}">'
|
|
f'<button class="copy-btn" data-code={escape_js(async_ex)}>copy</button>'
|
|
f'<pre><code class="language-python">{html.escape(async_ex)}</code></pre>'
|
|
f'</div>'
|
|
f'</div>'
|
|
f'</article>'
|
|
)
|
|
|
|
cards.append("</section>")
|
|
|
|
css = """
|
|
:root {
|
|
--bg: #0a0a0f;
|
|
--bg-2: #111118;
|
|
--bg-3: #1a1a24;
|
|
--fg: #e8e8f0;
|
|
--muted: #8b8ba0;
|
|
--accent: #ff0055;
|
|
--accent-2: #00f0ff;
|
|
--accent-3: #ccff00;
|
|
--border: #2a2a3a;
|
|
--get: #00f0ff;
|
|
--post: #ccff00;
|
|
--put: #aa88ff;
|
|
--patch: #ffaa00;
|
|
--delete: #ff0055;
|
|
--font: "JetBrains Mono", "Fira Code", Consolas, monospace;
|
|
--display: "Arial Black", Impact, sans-serif;
|
|
}
|
|
* { box-sizing: border-box; }
|
|
html, body { margin: 0; padding: 0; }
|
|
body {
|
|
background: var(--bg);
|
|
color: var(--fg);
|
|
font-family: var(--font);
|
|
line-height: 1.55;
|
|
min-height: 100vh;
|
|
overflow-x: hidden;
|
|
}
|
|
body::before {
|
|
content: "";
|
|
position: fixed;
|
|
inset: 0;
|
|
pointer-events: none;
|
|
background:
|
|
repeating-linear-gradient(
|
|
0deg,
|
|
rgba(0,0,0,0.15),
|
|
rgba(0,0,0,0.15) 1px,
|
|
transparent 1px,
|
|
transparent 4px
|
|
);
|
|
z-index: 1000;
|
|
}
|
|
.container {
|
|
display: grid;
|
|
grid-template-columns: 320px 1fr;
|
|
min-height: 100vh;
|
|
}
|
|
.sidebar {
|
|
position: sticky;
|
|
top: 0;
|
|
height: 100vh;
|
|
background: var(--bg-2);
|
|
border-right: 2px solid var(--border);
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
}
|
|
.sidebar-header {
|
|
padding: 1.25rem;
|
|
border-bottom: 2px dashed var(--accent);
|
|
}
|
|
.brand {
|
|
font-family: var(--display);
|
|
font-size: 1.6rem;
|
|
letter-spacing: -0.05em;
|
|
text-transform: uppercase;
|
|
color: var(--accent);
|
|
text-shadow: 2px 2px 0 var(--accent-2), -2px -2px 0 #000;
|
|
margin: 0 0 0.25rem;
|
|
animation: glitch 2.5s infinite;
|
|
}
|
|
.brand-sub {
|
|
color: var(--muted);
|
|
font-size: 0.75rem;
|
|
margin: 0;
|
|
}
|
|
.search-wrap {
|
|
padding: 0.75rem 1.25rem;
|
|
}
|
|
.search-wrap input {
|
|
width: 100%;
|
|
background: var(--bg);
|
|
border: 2px solid var(--border);
|
|
color: var(--fg);
|
|
padding: 0.6rem 0.8rem;
|
|
font-family: var(--font);
|
|
outline: none;
|
|
}
|
|
.search-wrap input:focus {
|
|
border-color: var(--accent);
|
|
box-shadow: 0 0 10px var(--accent);
|
|
}
|
|
.nav-scroll {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 0 0.75rem 1.5rem;
|
|
}
|
|
.ns-group {
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
.ns-toggle {
|
|
width: 100%;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
background: var(--bg-3);
|
|
border: 1px solid var(--border);
|
|
color: var(--fg);
|
|
padding: 0.55rem 0.7rem;
|
|
font-family: var(--font);
|
|
font-size: 0.85rem;
|
|
text-align: left;
|
|
cursor: pointer;
|
|
text-transform: uppercase;
|
|
}
|
|
.ns-toggle:hover { border-color: var(--accent-2); color: var(--accent-2); }
|
|
.ns-toggle[aria-expanded="true"] .ns-chevron { transform: rotate(90deg); }
|
|
.ns-chevron { transition: transform 0.15s; }
|
|
.ns-name { flex: 1; }
|
|
.ns-count {
|
|
background: var(--accent);
|
|
color: #000;
|
|
padding: 0.1rem 0.35rem;
|
|
font-size: 0.7rem;
|
|
font-weight: bold;
|
|
}
|
|
.ns-methods {
|
|
display: none;
|
|
flex-direction: column;
|
|
padding-left: 0.75rem;
|
|
border-left: 2px solid var(--border);
|
|
margin-left: 0.75rem;
|
|
}
|
|
.ns-methods.open { display: flex; }
|
|
.method-link {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
padding: 0.35rem 0.5rem;
|
|
color: var(--muted);
|
|
text-decoration: none;
|
|
font-size: 0.78rem;
|
|
}
|
|
.method-link:hover { color: var(--accent-3); background: rgba(255,255,255,0.03); }
|
|
.method-dot { width: 7px; height: 7px; border-radius: 50%; }
|
|
.method-dot.get { background: var(--get); }
|
|
.method-dot.post { background: var(--post); }
|
|
.method-dot.put { background: var(--put); }
|
|
.method-dot.patch { background: var(--patch); }
|
|
.method-dot.delete { background: var(--delete); }
|
|
.main {
|
|
padding: 2rem 2.5rem;
|
|
max-width: 1100px;
|
|
}
|
|
.hero {
|
|
margin-bottom: 2.5rem;
|
|
border: 2px solid var(--accent);
|
|
padding: 1.5rem;
|
|
background: var(--bg-2);
|
|
position: relative;
|
|
}
|
|
.hero::after {
|
|
content: "!!!";
|
|
position: absolute;
|
|
top: -0.8rem;
|
|
right: 1rem;
|
|
background: var(--bg);
|
|
color: var(--accent);
|
|
padding: 0 0.5rem;
|
|
font-family: var(--display);
|
|
font-size: 1.2rem;
|
|
}
|
|
.hero h1 {
|
|
font-family: var(--display);
|
|
text-transform: uppercase;
|
|
font-size: 2.4rem;
|
|
margin: 0 0 0.5rem;
|
|
color: var(--accent-2);
|
|
text-shadow: 3px 3px 0 var(--accent);
|
|
}
|
|
.hero p { margin: 0; color: var(--muted); }
|
|
.hero .stat {
|
|
margin-top: 1rem;
|
|
color: var(--accent-3);
|
|
font-weight: bold;
|
|
}
|
|
.namespace-section {
|
|
margin-bottom: 3rem;
|
|
}
|
|
.ns-header {
|
|
margin-bottom: 1.5rem;
|
|
border-bottom: 3px solid var(--border);
|
|
padding-bottom: 0.75rem;
|
|
}
|
|
.ns-title {
|
|
font-family: var(--display);
|
|
text-transform: uppercase;
|
|
font-size: 1.8rem;
|
|
margin: 0;
|
|
color: var(--accent-3);
|
|
}
|
|
.ns-desc { color: var(--muted); margin: 0.4rem 0 0; }
|
|
.method-card {
|
|
background: var(--bg-2);
|
|
border: 1px solid var(--border);
|
|
margin-bottom: 1.25rem;
|
|
padding: 1.25rem;
|
|
position: relative;
|
|
}
|
|
.method-card::before {
|
|
content: "";
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
width: 4px;
|
|
height: 100%;
|
|
background: var(--accent);
|
|
}
|
|
.method-card.get::before { background: var(--get); }
|
|
.method-card.post::before { background: var(--post); }
|
|
.method-card.put::before { background: var(--put); }
|
|
.method-card.patch::before { background: var(--patch); }
|
|
.method-card.delete::before { background: var(--delete); }
|
|
.method-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
margin-bottom: 0.4rem;
|
|
}
|
|
.method-name {
|
|
font-size: 1.15rem;
|
|
margin: 0;
|
|
color: var(--fg);
|
|
}
|
|
.http-badge {
|
|
font-size: 0.7rem;
|
|
font-weight: bold;
|
|
padding: 0.15rem 0.4rem;
|
|
border: 1px solid currentColor;
|
|
text-transform: uppercase;
|
|
}
|
|
.http-badge.get { color: var(--get); border-color: var(--get); }
|
|
.http-badge.post { color: var(--post); border-color: var(--post); }
|
|
.http-badge.put { color: var(--put); border-color: var(--put); }
|
|
.http-badge.patch { color: var(--patch); border-color: var(--patch); }
|
|
.http-badge.delete { color: var(--delete); border-color: var(--delete); }
|
|
.method-endpoint {
|
|
color: var(--accent-2);
|
|
font-size: 0.85rem;
|
|
margin: 0 0 0.6rem;
|
|
font-weight: bold;
|
|
}
|
|
.method-desc { color: var(--muted); margin: 0 0 1rem; }
|
|
.signature {
|
|
background: var(--bg);
|
|
border: 1px dashed var(--border);
|
|
padding: 0.75rem 1rem;
|
|
margin: 0 0 1rem;
|
|
overflow-x: auto;
|
|
}
|
|
.signature code {
|
|
font-family: var(--font);
|
|
color: var(--accent-2);
|
|
}
|
|
.example-tabs {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
.tab-btn {
|
|
background: var(--bg-3);
|
|
border: 1px solid var(--border);
|
|
color: var(--muted);
|
|
padding: 0.35rem 0.8rem;
|
|
font-family: var(--font);
|
|
cursor: pointer;
|
|
}
|
|
.tab-btn.active { border-color: var(--accent); color: var(--fg); }
|
|
.example-wrap { position: relative; }
|
|
.example {
|
|
display: none;
|
|
position: relative;
|
|
}
|
|
.example.active { display: block; }
|
|
.example pre {
|
|
background: var(--bg);
|
|
border: 1px solid var(--border);
|
|
padding: 1rem;
|
|
margin: 0;
|
|
overflow-x: auto;
|
|
}
|
|
.example code {
|
|
font-family: var(--font);
|
|
font-size: 0.82rem;
|
|
color: var(--fg);
|
|
}
|
|
.copy-btn {
|
|
position: absolute;
|
|
top: 0.5rem;
|
|
right: 0.5rem;
|
|
background: var(--bg-3);
|
|
border: 1px solid var(--border);
|
|
color: var(--muted);
|
|
padding: 0.25rem 0.5rem;
|
|
font-family: var(--font);
|
|
font-size: 0.7rem;
|
|
cursor: pointer;
|
|
z-index: 2;
|
|
}
|
|
.copy-btn:hover { border-color: var(--accent-2); color: var(--accent-2); }
|
|
.hidden { display: none !important; }
|
|
@keyframes glitch {
|
|
0%, 90%, 100% { transform: translate(0); }
|
|
91% { transform: translate(2px, 1px); }
|
|
92% { transform: translate(-2px, -1px); }
|
|
93% { transform: translate(1px, -1px); }
|
|
94% { transform: translate(0); }
|
|
}
|
|
@media (max-width: 900px) {
|
|
.container { grid-template-columns: 1fr; }
|
|
.sidebar { position: static; height: auto; }
|
|
}
|
|
"""
|
|
|
|
js = """
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
const search = document.getElementById('search');
|
|
const groups = document.querySelectorAll('.ns-group');
|
|
|
|
function updateSearch(term) {
|
|
const low = term.toLowerCase();
|
|
groups.forEach(g => {
|
|
const nsName = g.querySelector('.ns-name').textContent.toLowerCase();
|
|
const links = Array.from(g.querySelectorAll('.method-link'));
|
|
let nsMatch = nsName.includes(low);
|
|
links.forEach(lnk => {
|
|
const txt = lnk.textContent.toLowerCase();
|
|
const show = !term || nsMatch || txt.includes(low);
|
|
lnk.classList.toggle('hidden', !show);
|
|
});
|
|
const any = links.some(l => !l.classList.contains('hidden'));
|
|
g.classList.toggle('hidden', !any);
|
|
if (term && any) {
|
|
g.querySelector('.ns-methods').classList.add('open');
|
|
g.querySelector('.ns-toggle').setAttribute('aria-expanded', 'true');
|
|
}
|
|
});
|
|
}
|
|
|
|
search.addEventListener('input', e => updateSearch(e.target.value));
|
|
|
|
document.querySelectorAll('.ns-toggle').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
const open = btn.getAttribute('aria-expanded') === 'true';
|
|
btn.setAttribute('aria-expanded', String(!open));
|
|
const methods = document.getElementById('nav-' + btn.dataset.ns);
|
|
methods.classList.toggle('open');
|
|
});
|
|
});
|
|
|
|
document.querySelectorAll('.tab-btn').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
const parent = btn.closest('.method-card');
|
|
parent.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
|
btn.classList.add('active');
|
|
parent.querySelectorAll('.example').forEach(ex => ex.classList.remove('active'));
|
|
document.getElementById(btn.dataset.target).classList.add('active');
|
|
});
|
|
});
|
|
|
|
document.querySelectorAll('.copy-btn').forEach(btn => {
|
|
btn.addEventListener('click', async () => {
|
|
const code = btn.dataset.code || '';
|
|
try {
|
|
await navigator.clipboard.writeText(code);
|
|
const old = btn.textContent;
|
|
btn.textContent = 'copied';
|
|
setTimeout(() => btn.textContent = old, 1200);
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
});
|
|
});
|
|
|
|
// Open the group targeted by a deep link.
|
|
if (location.hash) {
|
|
const card = document.querySelector(location.hash);
|
|
if (card) {
|
|
const ns = card.closest('.namespace-section').id.replace('ns-', '');
|
|
const toggle = document.querySelector(`.ns-toggle[data-ns="${ns}"]`);
|
|
if (toggle) {
|
|
toggle.setAttribute('aria-expanded', 'true');
|
|
document.getElementById('nav-' + ns).classList.add('open');
|
|
}
|
|
card.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
}
|
|
}
|
|
});
|
|
"""
|
|
|
|
html_doc = f"""<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>CONDUIT CLIENT DOCS — PSYCHOTIC EDITION</title>
|
|
<style>{css}</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<aside class="sidebar">
|
|
<div class="sidebar-header">
|
|
<h1 class="brand">CONDUIT CLIENT</h1>
|
|
<p class="brand-sub">PSYCHOTIC API DOCUMENTATION</p>
|
|
</div>
|
|
<div class="search-wrap">
|
|
<input type="text" id="search" placeholder="FIND A METHOD..." autocomplete="off">
|
|
</div>
|
|
<nav class="nav-scroll">
|
|
{''.join(nav_items)}
|
|
</nav>
|
|
</aside>
|
|
<main class="main">
|
|
<div class="hero">
|
|
<h1>ABANDON HOPE</h1>
|
|
<p>Typed Python client for the Conduit unified API gateway. Every façade. Every method. Sync and async examples. Copy, paste, and pray.</p>
|
|
<p class="stat">{total_methods} METHODS ACROSS {len(namespaces)} FAÇADES</p>
|
|
</div>
|
|
{''.join(cards)}
|
|
</main>
|
|
</div>
|
|
<script>{js}</script>
|
|
</body>
|
|
</html>
|
|
"""
|
|
return html_doc
|
|
|
|
|
|
def as_json_data(namespaces: list[NamespaceInfo]) -> list[dict[str, Any]]:
|
|
"""Serialize namespaces/methods/examples to plain JSON."""
|
|
out: list[dict[str, Any]] = []
|
|
for ns in namespaces:
|
|
methods: list[dict[str, Any]] = []
|
|
for method in ns.methods:
|
|
methods.append(
|
|
{
|
|
"name": method.name,
|
|
"http_method": method.http_method,
|
|
"endpoint": method.endpoint,
|
|
"description": method.description,
|
|
"signature": signature_line(method),
|
|
"args": [
|
|
{
|
|
"name": a.name,
|
|
"annotation": a.annotation,
|
|
"default": a.default,
|
|
"keyword_only": a.is_keyword_only,
|
|
}
|
|
for a in method.args
|
|
],
|
|
"request_model": method.request_model,
|
|
"response_model": method.response_model,
|
|
"return_annotation": method.return_annotation,
|
|
"sync_example": generate_example(method, async_client=False),
|
|
"async_example": generate_example(method, async_client=True),
|
|
}
|
|
)
|
|
out.append(
|
|
{
|
|
"name": ns.name,
|
|
"title": ns.title,
|
|
"description": ns.description,
|
|
"sync_class": ns.sync_class,
|
|
"async_class": ns.async_class,
|
|
"methods": methods,
|
|
}
|
|
)
|
|
return out
|
|
|
|
|
|
def main() -> None:
|
|
site_dir = ROOT / "site"
|
|
site_dir.mkdir(exist_ok=True)
|
|
namespaces = build_namespaces()
|
|
|
|
html_doc = render_html(namespaces)
|
|
(site_dir / "index.html").write_text(html_doc, encoding="utf-8")
|
|
|
|
data = as_json_data(namespaces)
|
|
(site_dir / "api.json").write_text(
|
|
json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8"
|
|
)
|
|
|
|
total_methods = sum(len(ns.methods) for ns in namespaces)
|
|
print(f"Generated {site_dir / 'index.html'} and {site_dir / 'api.json'} ({len(namespaces)} namespaces, {total_methods} methods)")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|