import html import json from datetime import datetime from glob import glob import importlib.util from pathlib import Path import re import shutil import sys from fastapi import FastAPI # CDN pins for the spec viewers. SWAGGER_CSS = "https://unpkg.com/swagger-ui-dist@5.18.2/swagger-ui.css" SWAGGER_JS = "https://unpkg.com/swagger-ui-dist@5.18.2/swagger-ui-bundle.js" ELEMENTS_CSS = "https://unpkg.com/@stoplight/elements@9.0.24/styles.min.css" ELEMENTS_JS = "https://unpkg.com/@stoplight/elements@9.0.24/web-components.min.js" def version_sort_key(v: str) -> tuple: """Sort versions semver-ish (e.g. 'v4.1.0.7'); newest last when sorted ascending.""" parts = re.findall(r"\d+", v) return tuple(int(p) for p in parts) if parts else (0,) def embed_json(spec: dict) -> str: """Serialize `spec` for safe embedding in a `` would break out of the block; escape the slash. `\/` is a valid # JSON string escape and ` FastAPI: """Import an `app.py` located at mod_path and return its `app` FastAPI instance. The subject's own directory is temporarily prepended to sys.path so that sibling modules (e.g. `schemas`) resolve correctly. """ mod_name = f"subject_{mod_path.parent.parent.name}_{mod_path.parent.name}" spec = importlib.util.spec_from_file_location(mod_name, mod_path) if spec is None or spec.loader is None: raise ImportError(f"Could not load module from {mod_path}") module = importlib.util.module_from_spec(spec) # Ensure sibling imports (e.g. `import schemas`) resolve. parent = str(mod_path.parent) sys.path.insert(0, parent) try: sys.modules[mod_name] = module spec.loader.exec_module(module) finally: if parent in sys.path: sys.path.remove(parent) sys.modules.pop(mod_name, None) if not hasattr(module, "app") or not isinstance(module.app, FastAPI): raise AttributeError(f"{mod_path} does not expose a FastAPI `app` instance") return module.app def render_viewers(version_dir: Path, subject: str, version: str, spec: dict) -> None: """Write swagger.html and elements.html for one spec, embedding the JSON inline.""" spec_json = embed_json(spec) title = f"{subject} {version}" swagger_html = f""" {html.escape(title)} - Swagger UI
""" (version_dir / "swagger.html").write_text(swagger_html) elements_html = f""" {html.escape(title)} - Elements """ (version_dir / "elements.html").write_text(elements_html) def build_index(out_dir: Path, specs: dict[str, list[str]]) -> None: """Write a plain old-school index.html listing apps, versions, and viewer links.""" rows: list[str] = [] for subject in sorted(specs): versions = sorted(specs[subject], key=version_sort_key, reverse=True) rows.append(f'
  • {html.escape(subject)}') rows.append(" ") rows.append("
  • ") page = f""" OpenAPI Specs

    OpenAPI Specs



    Generated by Tangled Spindle - {datetime.now().strftime("%Y-%m-%d %H:%M:%S")} - Source
    """ (out_dir / "index.html").write_text(page) def main() -> None: out_dir = Path("specs") # Start from a clean slate so stale specs/views never linger. if out_dir.exists(): shutil.rmtree(out_dir) out_dir.mkdir() generated: dict[str, list[str]] = {} # Layout: subjects///app.py for app_path in sorted(glob("subjects/*/*/app.py")): mod_path = Path(app_path) subject = mod_path.parent.parent.name dir_version = mod_path.parent.name print(f"Generating OpenAPI spec for {subject} @ {dir_version} ...") app = dynamic_import(mod_path) # The directory version must match what the app declares, otherwise # the two sources of truth will silently drift. app_version = app.version if app_version != dir_version: raise ValueError( f"Version mismatch for {subject}: directory says " f"'{dir_version}' but app.version says '{app_version}'" ) version_dir = out_dir / subject / dir_version version_dir.mkdir(parents=True, exist_ok=True) openapi = app.openapi() (version_dir / "openapi.json").write_text( json.dumps(openapi, indent=2, ensure_ascii=False) ) render_viewers(version_dir, subject, dir_version, openapi) print(f" -> wrote {version_dir}/openapi.json, swagger.html, elements.html") generated.setdefault(subject, []).append(dir_version) build_index(out_dir, generated) print(f" -> wrote {out_dir / 'index.html'}") if __name__ == "__main__": main()