Files
cheechandcharliesnortan8bal…/scripts/generate_docs.py

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()