#!/usr/bin/env python3 """callqa: a small command line client for the CallQA API (Python 3.9+, stdlib only). Configuration (flags win over environment): CALLQA_API_KEY API key, "cqa__" (--api-key) CALLQA_API_URL default https://app.callqa.dev/api/v1 (--api-url) Commands: callqa me callqa run --scenario ID [--threshold 80] [--code-version v2.3.0] [--git-sha SHA] [--tag nightly] [--variant ID ...] [--wait] [--timeout 1800] [--summary FILE] [--json] callqa status RUN_ID [--json] callqa bundle export CALL_ID [-o FILE] callqa replay (--call ID | --bundle FILE) [--fresh-runtime] [--wait] callqa scenario import FILE.yaml Exit codes: 0 success; with --wait: the run finished and passed the quality gate 1 quality gate failed: verdict fail, score under --threshold, or a call ended in error / blocked (for example blocked_insufficient_credit) 2 usage or configuration error (missing key, bad arguments, unreadable file) 3 API or network error (401, 403, 404, 422, 5xx...), details on stderr 4 timed out while waiting; the run keeps going on the server 5 the run was canceled """ from __future__ import annotations import argparse import json import os import sys import time import urllib.error import urllib.parse import urllib.request __version__ = "0.1.0" DEFAULT_URL = "https://app.callqa.dev/api/v1" EXIT_OK, EXIT_GATE, EXIT_USAGE, EXIT_API, EXIT_TIMEOUT, EXIT_CANCELED = 0, 1, 2, 3, 4, 5 FINAL_CALL_STATUSES = {"completed", "error", "canceled", "blocked_insufficient_credit", "blocked_policy"} RETRY_STATUSES = {429, 502, 503, 504} class ApiError(Exception): def __init__(self, status: int, detail: str, code: str | None = None, errors=None): super().__init__(detail) self.status, self.detail, self.code, self.errors = status, detail, code, errors or [] def __str__(self) -> str: head = f"HTTP {self.status}: {self.detail}" if self.status else self.detail if self.code: head += f" ({self.code})" for e in self.errors: head += f"\n - {e.get('loc', '?')}: {e.get('msg', '')}" return head class Usage(Exception): pass def log(msg: str) -> None: print(msg, file=sys.stderr, flush=True) # --------------------------------------------------------------------------- HTTP client class Client: def __init__(self, base_url: str, api_key: str, max_retries: int = 5, sleep=time.sleep): self.base = base_url.rstrip("/") self.key = api_key self.max_retries = max_retries self.sleep = sleep def request(self, method: str, path: str, *, query: dict | None = None, body=None, content_type: str = "application/json", raw: bool = False, accept: tuple[int, ...] = ()): """Return (status, parsed JSON or bytes). Retries 429/502/503/504 and network errors. Only rate limiting (429) is retried for non-GET requests: a 429 is answered before the request does anything, so retrying it cannot create duplicates. """ url = self.base + path if query: q = {k: v for k, v in query.items() if v is not None} if q: url += "?" + urllib.parse.urlencode(q, doseq=True) data = None if body is not None: data = body if isinstance(body, bytes) else json.dumps(body).encode() headers = {"Authorization": f"Bearer {self.key}", "Accept": "application/json", "User-Agent": f"callqa-cli/{__version__}"} if data is not None: headers["Content-Type"] = content_type attempt = 0 while True: attempt += 1 req = urllib.request.Request(url, data=data, method=method, headers=headers) try: with urllib.request.urlopen(req, timeout=60) as resp: # noqa: S310 payload = resp.read() return resp.status, (payload if raw else _json(payload)) except urllib.error.HTTPError as exc: payload = exc.read() if exc.code in accept: return exc.code, _json(payload) retryable = exc.code == 429 or (method == "GET" and exc.code in RETRY_STATUSES) if retryable and attempt <= self.max_retries: wait = _retry_after(exc.headers.get("Retry-After"), attempt) log(f"HTTP {exc.code}, retrying in {wait}s") self.sleep(wait) continue doc = _json(payload) if payload else {} if not isinstance(doc, dict): doc = {} raise ApiError(exc.code, doc.get("detail") or exc.reason or "error", doc.get("code"), doc.get("errors")) except (urllib.error.URLError, TimeoutError, ConnectionError) as exc: if method == "GET" and attempt <= self.max_retries: wait = min(2 ** attempt, 30) log(f"network error ({exc}), retrying in {wait}s") self.sleep(wait) continue raise ApiError(0, f"cannot reach {self.base}: {exc}") def get(self, path, **kw): return self.request("GET", path, **kw)[1] def post(self, path, body=None, **kw): return self.request("POST", path, body=body, **kw) def _json(payload: bytes): try: return json.loads(payload.decode() or "null") except ValueError: return {"detail": payload.decode(errors="replace")[:500]} def _retry_after(value: str | None, attempt: int) -> int: try: return max(1, min(int(value), 120)) except (TypeError, ValueError): return min(2 ** attempt, 30) # --------------------------------------------------------------------------- gate & summary def gate(run: dict) -> int: """Map a finished run to an exit code (same rule as the server's CI mode).""" if run["status"] == "canceled": return EXIT_CANCELED ci = run.get("ci") or {} if ci.get("passed") is not None: return EXIT_OK if ci["passed"] else EXIT_GATE return EXIT_OK if run.get("verdict") == "pass" else EXIT_GATE def summary_markdown(run: dict, calls: list[dict], threshold: int | None) -> str: code = gate(run) if run["status"] in ("completed", "canceled") else None label = {EXIT_OK: "PASSED", EXIT_GATE: "FAILED", EXIT_CANCELED: "CANCELED", None: "RUNNING"}[code] score = "n/a" if run.get("score") is None else f"{run['score']}/100" lines = [f"### CallQA run #{run['number']}: {run.get('scenario_name') or 'replay'}", ""] extra = f" (threshold {threshold})" if threshold is not None else "" code_version = run.get("version") or run.get("release") rel = f", version `{code_version}`" if code_version else "" lines.append(f"**{label}**: score {score}{extra}, verdict `{run.get('verdict')}`{rel}.") lines += ["", "| Total | Passed | Failed | Warned | Errored | Blocked | Cost |", "|---|---|---|---|---|---|---|", "| {total} | {passed} | {failed} | {warned} | {errored} | {blocked} | ${c:.2f} |" .format(c=run.get("cost_cents", 0) / 100, **run), ""] if calls: lines += ["| Test case | Status | Verdict | Score | p95 latency |", "|---|---|---|---|---|"] for c in calls: p95 = f"{c['latency_p95_ms']} ms" if c.get("latency_p95_ms") is not None else "" status = c["status"] + (f" ({c['block_reason']})" if c.get("block_reason") else "") lines.append(f"| {c['title'] or c['id']} | {status} | {c.get('verdict') or ''} | " f"{'' if c.get('score') is None else c['score']} | {p95} |") lines.append("") if any(c["status"] == "blocked_insufficient_credit" for c in calls): lines += ["Some calls were not placed: the prepaid wallet is too low. Top it up in " "Billing, or with `POST /api/v1/wallet/checkout`.", ""] lines += [f"[Open the full report]({run['url']})", ""] return "\n".join(lines) def write_outputs(path: str | None, values: dict) -> None: if not path: return with open(path, "a", encoding="utf-8") as fh: for k, v in values.items(): fh.write(f"{k}={'' if v is None else v}\n") # --------------------------------------------------------------------------- commands def wait_run(client: Client, run: dict, timeout: float, poll: float, sleep=time.sleep) -> dict: deadline = time.monotonic() + timeout last = None while run["status"] not in ("completed", "canceled"): if time.monotonic() >= deadline: raise TimeoutError state = (run["status"], run["total"] - _pending(run)) if state != last: log(f"run #{run['number']}: {run['status']}, {state[1]}/{run['total']} calls done") last = state sleep(poll) run = client.get(f"/runs/{run['id']}") return run def _pending(run: dict) -> int: done = run["passed"] + run["failed"] + run["warned"] + run["errored"] + run["blocked"] return max(0, run["total"] - done) def cmd_me(client: Client, args) -> int: me = client.get("/me") if args.json: print(json.dumps(me, indent=2)) else: print(f"{me['org_name']} ({me['org_slug']}), plan {me['plan']['code']}, " f"key cqa_{me['api_key_prefix']}_..., scopes {', '.join(me['scopes'])}") return EXIT_OK def cmd_run(client: Client, args) -> int: if args.threshold is not None and not 0 <= args.threshold <= 100: raise Usage("--threshold must be between 0 and 100") body = {"ci": True, "tags": args.tag or []} version = args.code_version or args.release # --release: former name, still read if version: body["version"] = version for key in ("git_ref", "git_sha", "environment"): if getattr(args, key, None): body[key] = getattr(args, key) if args.variant: body["variants"] = args.variant status, run = client.post(f"/scenarios/{args.scenario}/runs", body, query={"threshold": args.threshold}) log(f"queued run #{run['number']} ({run['total']} calls): {run['url']}") write_outputs(args.outputs, {"run-id": run["id"], "run-url": run["url"]}) if not args.wait: if args.json: print(json.dumps(run, indent=2)) else: print(run["id"]) return EXIT_OK try: run = wait_run(client, run, args.timeout, args.poll) except TimeoutError: log(f"timed out after {args.timeout}s; the run continues: {run['url']}") return EXIT_TIMEOUT return finish(client, run, args) def finish(client: Client, run: dict, args) -> int: calls = client.get("/calls", query={"run_id": run["id"], "limit": 200})["items"] md = summary_markdown(run, calls, getattr(args, "threshold", None)) if getattr(args, "summary", None): with open(args.summary, "a", encoding="utf-8") as fh: fh.write(md + "\n") code = gate(run) write_outputs(getattr(args, "outputs", None), { "verdict": run.get("verdict"), "score": run.get("score"), "passed": "true" if code == EXIT_OK else "false"}) if args.json: print(json.dumps(run, indent=2)) else: print(md) return code def cmd_status(client: Client, args) -> int: run = client.get(f"/runs/{args.run_id}") if run["status"] in ("completed", "canceled"): return finish(client, run, args) print(json.dumps(run, indent=2) if args.json else f"run #{run['number']}: {run['status']} ({_pending(run)} of {run['total']} pending)") return EXIT_OK def cmd_bundle_export(client: Client, args) -> int: _, data = client.request("GET", f"/calls/{args.call_id}/bundle", raw=True) out = args.output or f"callqa-bundle-{args.call_id}.json" with open(out, "wb") as fh: fh.write(data) log(f"bundle written to {out}") print(out) return EXIT_OK def cmd_replay(client: Client, args) -> int: if bool(args.call) == bool(args.bundle): raise Usage("give exactly one of --call or --bundle") body: dict = {"same_runtime": not args.fresh_runtime} if args.call: body["call_id"] = args.call else: try: with open(args.bundle, encoding="utf-8") as fh: body["bundle"] = json.load(fh) except (OSError, ValueError) as exc: raise Usage(f"cannot read bundle {args.bundle}: {exc}") _, call = client.post("/replays", body) log(f"replay queued: call {call['id']} (run {call['run_id']})") if not args.wait: print(call["id"]) return EXIT_OK deadline = time.monotonic() + args.timeout while call["status"] not in FINAL_CALL_STATUSES: if time.monotonic() >= deadline: log("timed out; the call continues on the server") return EXIT_TIMEOUT time.sleep(args.poll) call = client.get(f"/calls/{call['id']}") print(json.dumps({k: call.get(k) for k in ("id", "status", "verdict", "score", "block_reason", "error")}, indent=2)) if call["status"] == "canceled": return EXIT_CANCELED return EXIT_OK if call.get("verdict") in ("pass", "warn") else EXIT_GATE def cmd_scenario_import(client: Client, args) -> int: try: with open(args.file, "rb") as fh: data = fh.read() except OSError as exc: raise Usage(f"cannot read {args.file}: {exc}") status, s = client.post("/scenarios/import", data, content_type="application/yaml") verb = "created" if status == 201 else "updated" log(f"{verb} scenario {s['name']!r} with {s['variants']} variants") print(s["id"]) return EXIT_OK # --------------------------------------------------------------------------- entry point def parser() -> argparse.ArgumentParser: p = argparse.ArgumentParser(prog="callqa", description="CallQA API command line client.", formatter_class=argparse.RawDescriptionHelpFormatter, epilog="Exit codes:" + __doc__.split("Exit codes:")[1]) p.add_argument("--api-url", default=os.environ.get("CALLQA_API_URL", DEFAULT_URL)) p.add_argument("--api-key", default=os.environ.get("CALLQA_API_KEY", "")) p.add_argument("--version", action="version", version=f"callqa {__version__}") sub = p.add_subparsers(dest="command", required=True) s = sub.add_parser("me", help="show the organization and scopes of the key") s.add_argument("--json", action="store_true") s.set_defaults(fn=cmd_me) s = sub.add_parser("run", help="trigger a run of a scenario") s.add_argument("--scenario", required=True, help="scenario id (UUID)") s.add_argument("--threshold", type=int, help="minimum score 0-100 to pass") s.add_argument("--code-version", help="version of your code under test, e.g. v2.3.0 " "(created in the environment if new)") s.add_argument("--git-sha", help="commit of that version") s.add_argument("--git-ref", help="branch or tag of that version") s.add_argument("--environment", help="environment slug (default: the default one)") s.add_argument("--release", help=argparse.SUPPRESS) # deprecated: --code-version s.add_argument("--tag", action="append", help="tag the run (repeatable)") s.add_argument("--variant", action="append", help="only this variant id (repeatable)") s.add_argument("--wait", action="store_true", help="wait for the result and gate on it") s.add_argument("--timeout", type=float, default=1800, help="seconds to wait (1800)") s.add_argument("--poll", type=float, default=10, help="seconds between polls (10)") s.add_argument("--summary", help="append a markdown summary to this file") s.add_argument("--outputs", help="append key=value outputs to this file") s.add_argument("--json", action="store_true", help="print the run as JSON") s.set_defaults(fn=cmd_run) s = sub.add_parser("status", help="show a run; gate on it if finished") s.add_argument("run_id") s.add_argument("--summary") s.add_argument("--json", action="store_true") s.set_defaults(fn=cmd_status, threshold=None, outputs=None) s = sub.add_parser("bundle", help="replay bundles") bsub = s.add_subparsers(dest="bundle_cmd", required=True) b = bsub.add_parser("export", help="download the replay bundle of a call") b.add_argument("call_id") b.add_argument("-o", "--output", help="file (default callqa-bundle-.json)") b.set_defaults(fn=cmd_bundle_export) s = sub.add_parser("replay", help="replay a call or a bundle file") s.add_argument("--call", help="call id of your organization") s.add_argument("--bundle", help="path to an exported bundle JSON") s.add_argument("--fresh-runtime", action="store_true", help="use today's models and voices instead of the bundle's") s.add_argument("--wait", action="store_true") s.add_argument("--timeout", type=float, default=1200) s.add_argument("--poll", type=float, default=10) s.set_defaults(fn=cmd_replay) s = sub.add_parser("scenario", help="scenarios") ssub = s.add_subparsers(dest="scenario_cmd", required=True) i = ssub.add_parser("import", help="create or update (by name) a scenario from YAML") i.add_argument("file") i.set_defaults(fn=cmd_scenario_import) return p def main(argv: list[str] | None = None) -> int: args = parser().parse_args(argv) if not args.api_key: log("error: no API key. Set CALLQA_API_KEY or pass --api-key.") return EXIT_USAGE client = Client(args.api_url, args.api_key) try: return args.fn(client, args) except Usage as exc: log(f"error: {exc}") return EXIT_USAGE except ApiError as exc: log(f"error: {exc}") return EXIT_API if __name__ == "__main__": sys.exit(main())