"""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("", "") 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__\") 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__\") 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'' f'' f'{html.escape(method.name)}' f'' ) nav_items.append( f'
' f'' f'' f'
' ) cards.append( f'
' f'
' f'

{html.escape(ns.name)}

' f'

{html.escape(ns.description or f"Methods for the {ns.title} façade.")}

' f'
' ) 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'
' f'
' f'

{html.escape(method.name)}

' f'{html.escape(method.http_method)}' f'
' f'

{html.escape(method.endpoint)}

' f'

{html.escape(method.description)}

' f'
{html.escape(sig)}
' f'
' f'' f'' f'
' f'
' f'
' f'' f'
{html.escape(sync_ex)}
' f'
' f'
' f'' f'
{html.escape(async_ex)}
' f'
' f'
' f'
' ) cards.append("
") 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""" CONDUIT CLIENT DOCS — PSYCHOTIC EDITION

ABANDON HOPE

Typed Python client for the Conduit unified API gateway. Every façade. Every method. Sync and async examples. Copy, paste, and pray.

{total_methods} METHODS ACROSS {len(namespaces)} FAÇADES

{''.join(cards)}
""" 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()