Ctrl K
ComunidadGuías…

scripts/wait-for-docs-spec.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env python3
"""Wait until Speakeasy's hosted docs spec reflects this release's OpenAPI spec.

The hosted spec is the base spec plus x-codeSamples from each SDK, rebuilt after
`speakeasy run` uploads. Mintlify only re-reads it on deploy, so redeploy the
docs after this passes.

Usage: scripts/wait-for-docs-spec.py SPEC [--url URL] [--timeout SECONDS] [--save PATH] [--exact]
"""

import argparse
import json
import os
import sys
import time
import urllib.request

DEFAULT_URL = "https://spec.speakeasy.com/flexprice/prod/swagger-json-with-code-samples"
SAMPLE_LANGS = {"go", "python", "typescript"}
HTTP_METHODS = {"get", "put", "post", "delete", "patch", "head", "options", "trace"}
# Added on top of the base spec by the SDK overlay and the code samples.
ADDED_KEYS = {"x-codeSamples", "x-speakeasy-name-override"}


def strip_added(node):
    if isinstance(node, dict):
        return {k: strip_added(v) for k, v in node.items() if k not in ADDED_KEYS}
    if isinstance(node, list):
        return [strip_added(v) for v in node]
    return node


def operations_missing_samples(spec):
    missing = []
    for path, item in spec.get("paths", {}).items():
        for method, op in item.items():
            if method not in HTTP_METHODS or op.get("deprecated"):
                continue
            langs = {sample.get("lang") for sample in op.get("x-codeSamples", [])}
            if not SAMPLE_LANGS <= langs:
                missing.append(f"{method.upper()} {path}")
    return missing


def sample_counts(spec):
    counts = dict.fromkeys(sorted(SAMPLE_LANGS), 0)
    for item in spec.get("paths", {}).values():
        for method, op in item.items():
            if method not in HTTP_METHODS:
                continue
            for lang in {sample.get("lang") for sample in op.get("x-codeSamples", [])} & SAMPLE_LANGS:
                counts[lang] += 1
    return counts


def fetch(url):
    opener = urllib.request.OpenerDirector()
    opener.add_handler(urllib.request.HTTPSHandler())
    opener.add_handler(urllib.request.UnknownHandler())
    with opener.open(url, timeout=60) as resp:
        if resp.status != 200:
            raise RuntimeError(f"HTTP {resp.status} from {url}")
        return resp.read()


def check(body, expected, *, exact=False):
    """Returns (why the hosted spec is stale or None, operations with samples per language)."""
    hosted = json.loads(body)
    if exact:
        if hosted != expected:
            return "full spec does not match the saved spec yet", None
    elif strip_added(hosted) != expected:
        return "base spec does not match this release yet", None
    missing = operations_missing_samples(hosted)
    if missing:
        return f"{len(missing)} operations have no code samples yet (e.g. {missing[0]})", None
    return None, sample_counts(hosted)


def write_step_summary(message):
    path = os.environ.get("GITHUB_STEP_SUMMARY")
    if path:
        with open(path, "a") as f:
            f.write(f"### Hosted docs spec\n\n{message}\n")


def main():
    parser = argparse.ArgumentParser(description="Wait for the hosted docs spec to match a local OpenAPI spec.")
    parser.add_argument("spec", help="local OpenAPI spec to compare against")
    parser.add_argument("--url", default=DEFAULT_URL, help="hosted spec URL (default: %(default)s)")
    parser.add_argument("--timeout", type=int, default=300, help="seconds to wait (default: %(default)s)")
    parser.add_argument("--interval", type=int, default=30, help="seconds between checks (default: %(default)s)")
    parser.add_argument("--save", help="once the hosted spec matches, write it to this path unchanged")
    parser.add_argument("--exact", action="store_true", help="compare the full JSON document, including code samples and SDK overlays")
    args = parser.parse_args()

    with open(args.spec) as f:
        expected = json.load(f)
    if not args.exact:
        expected = strip_added(expected)

    deadline = time.monotonic() + args.timeout
    while True:
        try:
            body = fetch(args.url)
            reason, counts = check(body, expected, exact=args.exact)
        except Exception as err:  # transient fetch or parse errors: keep polling until the deadline
            reason, counts = f"fetch failed: {err}", None
        if reason is None:
            if args.save:
                with open(args.save, "wb") as f:
                    f.write(body)
            samples = ", ".join(f"{lang} {n}" for lang, n in counts.items())
            message = f"{args.url} matches {args.spec}. Operations with code samples: {samples}."
            print(message, flush=True)
            write_step_summary(message)
            return 0
        if time.monotonic() >= deadline:
            print(f"::error::Hosted docs spec is still stale after {args.timeout}s: {reason}", flush=True)
            return 1
        print(f"Waiting for hosted docs spec: {reason}", flush=True)
        time.sleep(args.interval)


if __name__ == "__main__":
    sys.exit(main())