#!/usr/bin/env python3 """Generate docs/snippets/provider-cards.mdx from provider getting-started pages. Scans docs/user-guide/providers//getting-started-*.mdx, keeps only the providers that Prowler Cloud and Prowler Local Server actually support (source of truth: the `ProviderChoices` enum in api/src/backend/api/models.py — CLI-only providers such as Linode/LLM/Scaleway/StackIT are excluded), reads the frontmatter `title`, derives a display name, and emits a snippet exporting a `ProviderCards` component. Wired into pre-commit so the snippet stays in sync whenever a provider page or the API enum changes. """ from __future__ import annotations import ast import re import sys from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[2] PROVIDERS_DIR = REPO_ROOT / "docs" / "user-guide" / "providers" SNIPPET_PATH = REPO_ROOT / "docs" / "snippets" / "provider-cards.mdx" API_MODELS_PATH = REPO_ROOT / "api" / "src" / "backend" / "api" / "models.py" # Docs folder names that don't match the API enum key. Keep tiny — only rename # entries when the docs folder disagrees with the API-side identifier. DOCS_DIR_TO_API_KEY = { "microsoft365": "m365", "oci": "oraclecloud", } # Folder-name → Mintlify icon override. Providers not listed fall back to # DEFAULT_ICON. Add an entry only when the default looks wrong for a provider. ICON_OVERRIDES = { "alibabacloud": "cloud", "aws": "aws", "azure": "microsoft", "cloudflare": "cloudflare", "gcp": "google", "github": "github", "googleworkspace": "users", "iac": "code", "image": "docker", "kubernetes": "dharmachakra", "microsoft365": "briefcase", "mongodbatlas": "leaf", "oci": "database", "okta": "key", "openstack": "cubes", "vercel": "triangle", } DEFAULT_ICON = "cloud" TITLE_RE = re.compile(r"^\s*title\s*:\s*['\"](?P.+?)['\"]\s*$", re.MULTILINE) NAME_CLEANUP_RE = re.compile( r"^Getting Started [Ww]ith (?:the )?(?P<name>.+?)(?: on Prowler)?(?: Provider)?$" ) def app_supported_provider_keys() -> set[str]: """Return the set of provider keys declared in the API's ProviderChoices enum. Uses ast rather than regex so formatting changes, decorators, comments, or multi-line values in the enum body don't silently drop or invent providers. """ tree = ast.parse(API_MODELS_PATH.read_text(encoding="utf-8")) for node in ast.walk(tree): if not (isinstance(node, ast.ClassDef) and node.name == "ProviderChoices"): continue keys: set[str] = set() for item in node.body: if not isinstance(item, ast.Assign): continue value = item.value # Django TextChoices members look like: NAME = "key", _("Label") # which parses as an ast.Tuple whose first element is the key. if isinstance(value, ast.Tuple) and value.elts: first = value.elts[0] if isinstance(first, ast.Constant) and isinstance(first.value, str): keys.add(first.value) return keys raise RuntimeError( f"Could not locate ProviderChoices class in {API_MODELS_PATH.relative_to(REPO_ROOT)}" ) def extract_title(mdx_path: Path) -> str: text = mdx_path.read_text(encoding="utf-8") match = TITLE_RE.search(text) if not match: raise ValueError(f"No frontmatter title in {mdx_path}") return match.group("title") def display_name(title: str) -> str: match = NAME_CLEANUP_RE.match(title) return match.group("name") if match else title def collect_providers() -> list[dict]: supported = app_supported_provider_keys() providers = [] for provider_dir in sorted(PROVIDERS_DIR.iterdir()): if not provider_dir.is_dir(): continue api_key = DOCS_DIR_TO_API_KEY.get(provider_dir.name, provider_dir.name) if api_key not in supported: continue pages = sorted(provider_dir.glob("getting-started-*.mdx")) if not pages: continue page = pages[0] name = display_name(extract_title(page)) href = f"/user-guide/providers/{provider_dir.name}/{page.stem}" icon = ICON_OVERRIDES.get(provider_dir.name, DEFAULT_ICON) providers.append({"name": name, "href": href, "icon": icon}) providers.sort(key=lambda p: p["name"].lower()) return providers def render_snippet(providers: list[dict]) -> str: cards = "\n".join( f' <Card title="{p["name"]}" icon="{p["icon"]}" href="{p["href"]}" />' for p in providers ) return ( "{/* AUTO-GENERATED by docs/scripts/generate_provider_cards.py — do not edit by hand. */}\n" "{/* Regenerated on pre-commit whenever any provider getting-started page changes. */}\n" "\n" "export const ProviderCards = () => (\n" " <Columns cols={3}>\n" f"{cards}\n" " </Columns>\n" ");\n" ) def main() -> int: providers = collect_providers() if not providers: print("No provider getting-started pages found", file=sys.stderr) return 1 new_content = render_snippet(providers) current = SNIPPET_PATH.read_text(encoding="utf-8") if SNIPPET_PATH.exists() else "" if new_content == current: return 0 SNIPPET_PATH.write_text(new_content, encoding="utf-8") print( f"Regenerated {SNIPPET_PATH.relative_to(REPO_ROOT)} ({len(providers)} providers)" ) return 1 # signal pre-commit that the file changed if __name__ == "__main__": sys.exit(main())