workflowval 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. workflowval/__init__.py +3 -0
  2. workflowval/cli.py +584 -0
  3. workflowval/gen/__init__.py +194 -0
  4. workflowval/gen/source.py +109 -0
  5. workflowval/gen/style.md +17 -0
  6. workflowval/gen/templates/.gitkeep +0 -0
  7. workflowval/gen/templates/conftest.py.tmpl +332 -0
  8. workflowval/loop/__init__.py +55 -0
  9. workflowval/loop/runner.py +488 -0
  10. workflowval/loop/stats.py +178 -0
  11. workflowval/loop/trace.py +29 -0
  12. workflowval/models/__init__.py +19 -0
  13. workflowval/models/base.py +68 -0
  14. workflowval/models/fake.py +55 -0
  15. workflowval/models/litellm_adapter.py +153 -0
  16. workflowval/pipeline.py +906 -0
  17. workflowval/planner/__init__.py +62 -0
  18. workflowval/prompts/__init__.py +34 -0
  19. workflowval/prompts/planner.md +37 -0
  20. workflowval/prompts/reviewer.md +30 -0
  21. workflowval/prompts/task_answer_review.md +14 -0
  22. workflowval/prompts/task_finding.md +17 -0
  23. workflowval/prompts/task_fix_test.md +6 -0
  24. workflowval/prompts/task_fix_workflow.md +7 -0
  25. workflowval/prompts/task_patch.md +9 -0
  26. workflowval/prompts/task_plan.md +13 -0
  27. workflowval/prompts/task_plan_gaps.md +6 -0
  28. workflowval/prompts/task_report.md +9 -0
  29. workflowval/prompts/task_review_plan.md +17 -0
  30. workflowval/prompts/task_review_results.md +19 -0
  31. workflowval/prompts/task_triage.md +9 -0
  32. workflowval/prompts/writer.md +11 -0
  33. workflowval/py.typed +0 -0
  34. workflowval/redact.py +97 -0
  35. workflowval/report/__init__.py +162 -0
  36. workflowval/report/curl.py +75 -0
  37. workflowval/schemas/__init__.py +78 -0
  38. workflowval/schemas/_base.py +43 -0
  39. workflowval/schemas/auth.py +66 -0
  40. workflowval/schemas/labels.py +31 -0
  41. workflowval/schemas/results.py +222 -0
  42. workflowval/schemas/review.py +94 -0
  43. workflowval/schemas/state.py +32 -0
  44. workflowval/schemas/stats.py +36 -0
  45. workflowval/schemas/workflow.py +148 -0
  46. workflowval/spec/__init__.py +38 -0
  47. workflowval/spec/graph.py +196 -0
  48. workflowval/spec/loader.py +102 -0
  49. workflowval/spec/validation.py +98 -0
  50. workflowval/testrun.py +159 -0
  51. workflowval/tools/__init__.py +6 -0
  52. workflowval/tools/base.py +49 -0
  53. workflowval/tools/registry.py +637 -0
  54. workflowval/triage/__init__.py +199 -0
  55. workflowval-0.1.0.dist-info/METADATA +215 -0
  56. workflowval-0.1.0.dist-info/RECORD +59 -0
  57. workflowval-0.1.0.dist-info/WHEEL +4 -0
  58. workflowval-0.1.0.dist-info/entry_points.txt +2 -0
  59. workflowval-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,3 @@
1
+ """workflowval: API workflow tests from an OpenAPI spec."""
2
+
3
+ __version__ = "0.1.0"
workflowval/cli.py ADDED
@@ -0,0 +1,584 @@
1
+ """The workflowval command line. Flags that aren't built yet refuse to run, never silently no-op."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from collections.abc import Callable, Sequence
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import Annotated
11
+ from urllib.parse import urlparse
12
+
13
+ import typer
14
+
15
+ from workflowval import __version__
16
+ from workflowval.loop import Budget, StopReason
17
+ from workflowval.models import LiteLLMModel, Model
18
+ from workflowval.pipeline import (
19
+ TestConfig,
20
+ TestRunReport,
21
+ auth_env_names,
22
+ results_from_outcomes,
23
+ run_drift_pipeline,
24
+ run_test_pipeline,
25
+ )
26
+ from workflowval.report import write_reports
27
+ from workflowval.schemas import AuthConfig, Label, RunResults, TestStatus, load_json
28
+ from workflowval.spec import SpecError, build_graph, load_spec
29
+ from workflowval.testrun import run_tests
30
+ from workflowval.tools import ROLE_TOOLS
31
+
32
+ SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
33
+ EXIT_FAILURES = 1
34
+ LOCAL_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"})
35
+
36
+ HELP = """Infer multi-step API workflows from an OpenAPI spec, test them and triage every failure.
37
+
38
+ \b
39
+ Flags (see each command's --help for the ones it takes):
40
+ --model provider/name Model for all roles; or set WORKFLOWVAL_MODEL
41
+ --role-model role=provider/name Override the model for one role
42
+ --max-review-rounds N Plan review rounds (default 2)
43
+ --max-fix-attempts N Fixes per test before UNRESOLVED (default 3)
44
+ --max-iterations N Test-run passes per run (default 10)
45
+ --max-steps N Model calls per run (default 200)
46
+ --max-cost-usd N Stop before a model call could pass this cost (default: no limit)
47
+ --read-only Block POST, PUT, PATCH and DELETE
48
+ --no-redact Keep response bodies exact, for local APIs only
49
+ --allow-host HOST Host that may be called (default: the base URL's host)
50
+ --allow-remote Allow hosts other than localhost
51
+ --yes Skip the prompt before writes to a non-local host
52
+ --ci Non-interactive; exit non-zero on failing results
53
+ --plan-only Stop after writing the workflow files
54
+ --verbose Echo the trace to the console
55
+ --no-cost-tracking Don't report cost; or set WORKFLOWVAL_COST_TRACKING=off
56
+ """
57
+
58
+ app = typer.Typer(help=HELP, no_args_is_help=True, rich_markup_mode=None, add_completion=False)
59
+
60
+ Spec = Annotated[str, typer.Argument(help="OpenAPI 3.0/3.1 JSON spec: a file path or URL.")]
61
+ BaseUrl = Annotated[str, typer.Option("--base-url", help="Base URL of the live API.")]
62
+ OptionalBaseUrl = Annotated[
63
+ str | None, typer.Option("--base-url", help="Base URL of the live API.")
64
+ ]
65
+ ModelOpt = Annotated[
66
+ str | None, typer.Option("--model", help="provider/name; or set WORKFLOWVAL_MODEL.")
67
+ ]
68
+ RoleModel = Annotated[
69
+ list[str] | None,
70
+ typer.Option("--role-model", help="role=provider/name, e.g. writer=provider/cheap-model."),
71
+ ]
72
+ Docs = Annotated[Path | None, typer.Option("--docs", help="Folder of Markdown, HTML or text docs.")]
73
+ Auth = Annotated[Path | None, typer.Option("--auth", help="Auth config file (JSON).")]
74
+ Report = Annotated[Path | None, typer.Option("--report", help="Directory for reports.")]
75
+ ReviewRounds = Annotated[
76
+ int, typer.Option("--max-review-rounds", min=0, help="Plan review rounds.")
77
+ ]
78
+ FixAttempts = Annotated[
79
+ int, typer.Option("--max-fix-attempts", min=0, help="Fixes per test before UNRESOLVED.")
80
+ ]
81
+ Iterations = Annotated[
82
+ int, typer.Option("--max-iterations", min=1, help="Test-run passes per run.")
83
+ ]
84
+ Steps = Annotated[int, typer.Option("--max-steps", min=1, help="Model calls per run.")]
85
+ MaxCost = Annotated[
86
+ float | None,
87
+ typer.Option("--max-cost-usd", min=0.0, help="Stop before a model call could pass this cost."),
88
+ ]
89
+ ReadOnly = Annotated[bool, typer.Option("--read-only", help="Block POST, PUT, PATCH and DELETE.")]
90
+ NoRedact = Annotated[
91
+ bool,
92
+ typer.Option("--no-redact", help="Keep response bodies exact. Local APIs only."),
93
+ ]
94
+ AllowHost = Annotated[
95
+ list[str] | None,
96
+ typer.Option("--allow-host", help="Host that may be called. Default: the base URL's host."),
97
+ ]
98
+ AllowRemote = Annotated[
99
+ bool, typer.Option("--allow-remote", help="Allow hosts other than localhost.")
100
+ ]
101
+ Yes = Annotated[bool, typer.Option("--yes", help="Skip the prompt before non-local writes.")]
102
+ Ci = Annotated[
103
+ bool, typer.Option("--ci", help="Non-interactive; exit non-zero on failing results.")
104
+ ]
105
+ CiApiBugs = Annotated[
106
+ int, typer.Option("--ci-max-api-bugs", min=0, help="API_BUG count --ci tolerates.")
107
+ ]
108
+ CiSpecDrift = Annotated[
109
+ int, typer.Option("--ci-max-spec-drift", min=0, help="SPEC_DRIFT count --ci tolerates.")
110
+ ]
111
+ PlanOnly = Annotated[bool, typer.Option("--plan-only", help="Stop after writing workflow files.")]
112
+ Verbose = Annotated[bool, typer.Option("--verbose", help="Echo the trace to the console.")]
113
+ Seed = Annotated[int | None, typer.Option("--seed", help="Model seed, where supported.")]
114
+ Temperature = Annotated[
115
+ float | None, typer.Option("--temperature", min=0.0, help="Model temperature.")
116
+ ]
117
+ NoCost = Annotated[
118
+ bool,
119
+ typer.Option(
120
+ "--no-cost-tracking", help="Don't report cost; or set WORKFLOWVAL_COST_TRACKING=off."
121
+ ),
122
+ ]
123
+
124
+
125
+ @dataclass(frozen=True)
126
+ class ModelChoice:
127
+ """The default model and per-role overrides."""
128
+
129
+ default: str | None
130
+ roles: dict[str, str]
131
+
132
+ def for_role(self, role: str) -> str | None:
133
+ """Return the model a role runs on."""
134
+ return self.roles.get(role, self.default)
135
+
136
+
137
+ def _check_model_name(name: str, source: str) -> str:
138
+ provider, _, model = name.partition("/")
139
+ if not provider or not model:
140
+ raise typer.BadParameter(f"{source} must look like provider/name, got {name!r}")
141
+ return name
142
+
143
+
144
+ def resolve_models(
145
+ model: str | None,
146
+ role_models: list[str] | None,
147
+ environ: dict[str, str] | None = None,
148
+ ) -> ModelChoice:
149
+ """Apply the order: --model, then WORKFLOWVAL_MODEL; --role-model overrides either."""
150
+ env = os.environ if environ is None else environ
151
+ default = model
152
+ source = "--model"
153
+ if default is None and env.get("WORKFLOWVAL_MODEL"):
154
+ default, source = env["WORKFLOWVAL_MODEL"], "WORKFLOWVAL_MODEL"
155
+ if default is not None:
156
+ _check_model_name(default, source)
157
+ roles: dict[str, str] = {}
158
+ for item in role_models or []:
159
+ role, separator, name = item.partition("=")
160
+ if not separator:
161
+ raise typer.BadParameter(f"--role-model must look like role=provider/name: {item!r}")
162
+ if role not in ROLE_TOOLS:
163
+ raise typer.BadParameter(f"unknown role {role!r}; roles are {sorted(ROLE_TOOLS)}")
164
+ if role in roles:
165
+ raise typer.BadParameter(f"--role-model given twice for {role!r}")
166
+ roles[role] = _check_model_name(name, "--role-model")
167
+ return ModelChoice(default, roles)
168
+
169
+
170
+ def check_host(
171
+ base_url: str,
172
+ allow_hosts: list[str] | None,
173
+ *,
174
+ allow_remote: bool,
175
+ yes: bool,
176
+ read_only: bool = False,
177
+ planned_writes: Sequence[str] = (),
178
+ ) -> None:
179
+ """Enforce NFR-1 and NFR-2: only allowed hosts, and nothing remote without explicit consent."""
180
+ host = urlparse(base_url).hostname
181
+ if host is None:
182
+ raise typer.BadParameter(f"--base-url must be a full URL, got {base_url!r}")
183
+ if allow_hosts and host not in allow_hosts:
184
+ raise typer.BadParameter(f"{host} isn't in --allow-host")
185
+ if host in LOCAL_HOSTS:
186
+ return
187
+ if not allow_remote:
188
+ raise typer.BadParameter(f"{host} isn't local: pass --allow-remote to call it")
189
+ if read_only:
190
+ return
191
+ if planned_writes:
192
+ typer.echo(f"Planned writes to {host}: {', '.join(planned_writes)}")
193
+ if not yes:
194
+ raise typer.BadParameter(f"tests write to {host}: pass --yes or --read-only to confirm")
195
+
196
+
197
+ def _writes_in_spec(spec: str) -> list[str]:
198
+ """The operations that write, to show before running against a non-local host."""
199
+ try:
200
+ operations = build_graph(load_spec(spec)).operations
201
+ except SpecError:
202
+ return [] # The pipeline reports a bad spec properly.
203
+ return [op.operation_id for op in operations if op.method.upper() not in SAFE_METHODS]
204
+
205
+
206
+ def _ci_exit(run_results: RunResults, *, max_api_bugs: int, max_spec_drift: int) -> None:
207
+ """Fail the build on anything unresolved, or on bugs above the thresholds (FR-12)."""
208
+ reasons = run_results.ci_failures(max_api_bugs=max_api_bugs, max_spec_drift=max_spec_drift)
209
+ if reasons:
210
+ typer.echo(f"CI failed: {'; '.join(reasons)}", err=True)
211
+ raise typer.Exit(EXIT_FAILURES)
212
+
213
+
214
+ def _load_auth(path: Path | None) -> AuthConfig | None:
215
+ if path is None:
216
+ return None
217
+ try:
218
+ return load_json(AuthConfig, path)
219
+ except (OSError, ValueError) as exc:
220
+ raise typer.BadParameter(f"can't read auth config {path}: {exc}") from exc
221
+
222
+
223
+ def _check_auth_env(auth: AuthConfig | None) -> None:
224
+ missing = [name for name in auth_env_names(auth) if not os.environ.get(name)]
225
+ if missing:
226
+ raise typer.BadParameter(f"auth config needs env vars that aren't set: {missing}")
227
+
228
+
229
+ def _build_models(
230
+ choice: ModelChoice, seed: int | None, temperature: float | None, cost_tracked: bool
231
+ ) -> dict[str, Model]:
232
+ models: dict[str, Model] = {}
233
+ for role in ROLE_TOOLS:
234
+ name = choice.for_role(role)
235
+ if name is None:
236
+ raise typer.BadParameter(
237
+ f"no model for {role}: pass --model provider/name or set WORKFLOWVAL_MODEL"
238
+ )
239
+ models[role] = LiteLLMModel(
240
+ name, temperature=temperature, seed=seed, track_cost=cost_tracked
241
+ )
242
+ return models
243
+
244
+
245
+ def _check_no_redact(no_redact: bool, base_url: str | None) -> None:
246
+ """Exact responses may hold real secrets, so they are kept only for a local API."""
247
+ if not no_redact:
248
+ return
249
+ if base_url is None:
250
+ raise typer.BadParameter("--no-redact needs --base-url, so the host can be checked")
251
+ if urlparse(base_url).hostname not in LOCAL_HOSTS:
252
+ raise typer.BadParameter("--no-redact works only with a local API")
253
+
254
+
255
+ def _check_cost_limit(max_cost_usd: float | None, tracked: bool) -> None:
256
+ if max_cost_usd is not None and not tracked:
257
+ raise typer.BadParameter("--max-cost-usd needs cost tracking, which is turned off")
258
+
259
+
260
+ def _cost_tracked(no_cost_tracking: bool) -> bool:
261
+ return not no_cost_tracking and os.environ.get("WORKFLOWVAL_COST_TRACKING", "") != "off"
262
+
263
+
264
+ def _print_report(report: TestRunReport) -> None:
265
+ for test in report.results.tests:
266
+ verdict = test.verdict
267
+ if verdict is None:
268
+ typer.echo(f"PASSED {test.workflow_id}")
269
+ elif test.status is TestStatus.XFAILED:
270
+ typer.echo(f"EXPECTED {test.workflow_id} ({verdict.label.value}): {verdict.reason}")
271
+ else:
272
+ typer.echo(f"{verdict.label.value:<11} {test.workflow_id}: {verdict.reason}")
273
+ for change in report.expectation_changes:
274
+ typer.echo(f"CHANGED {change}")
275
+ for fixed in report.looks_fixed:
276
+ typer.echo(f"LOOKS FIXED {fixed}")
277
+ if report.resumed:
278
+ typer.echo("RESUMED carried on from where the last run was killed")
279
+ typer.echo(report.results.summary())
280
+ if report.results.uncovered_operations:
281
+ operations = ", ".join(report.results.uncovered_operations)
282
+ typer.echo(f"NOT COVERED {operations}")
283
+ for finding in report.results.findings:
284
+ label = finding.label.value if finding.label else "NOT REVIEWED"
285
+ if finding.label is not Label.TEST_BUG:
286
+ typer.echo(f"MISMATCH {label}: {finding.message}")
287
+ for stopped in report.results.tasks_stopped:
288
+ typer.echo(f"LIMIT {stopped}")
289
+ typer.echo(report.run.summary)
290
+ typer.echo(f"results: {report.results_path}")
291
+
292
+
293
+ def _run_with(pipeline: Callable[[TestConfig], TestRunReport], config: TestConfig) -> TestRunReport:
294
+ try:
295
+ return pipeline(config)
296
+ except SpecError as exc:
297
+ typer.echo(f"Error: {exc}", err=True)
298
+ raise typer.Exit(2) from exc
299
+
300
+
301
+ def _run_pipeline(config: TestConfig) -> TestRunReport:
302
+ return _run_with(run_test_pipeline, config)
303
+
304
+
305
+ def _version(value: bool) -> None:
306
+ if value:
307
+ typer.echo(f"workflowval {__version__}")
308
+ raise typer.Exit
309
+
310
+
311
+ @app.callback()
312
+ def main(
313
+ version: Annotated[
314
+ bool,
315
+ typer.Option("--version", callback=_version, is_eager=True, help="Show the version."),
316
+ ] = False,
317
+ ) -> None:
318
+ """Top-level options."""
319
+
320
+
321
+ @app.command("test")
322
+ def test_command(
323
+ spec: Spec,
324
+ base_url: BaseUrl,
325
+ docs: Docs = None,
326
+ auth: Auth = None,
327
+ report: Report = None,
328
+ model: ModelOpt = None,
329
+ role_model: RoleModel = None,
330
+ max_review_rounds: ReviewRounds = 2,
331
+ max_fix_attempts: FixAttempts = 3,
332
+ max_iterations: Iterations = 10,
333
+ max_steps: Steps = 200,
334
+ max_cost_usd: MaxCost = None,
335
+ read_only: ReadOnly = False,
336
+ no_redact: NoRedact = False,
337
+ allow_host: AllowHost = None,
338
+ allow_remote: AllowRemote = False,
339
+ yes: Yes = False,
340
+ ci: Ci = False,
341
+ ci_max_api_bugs: CiApiBugs = 0,
342
+ ci_max_spec_drift: CiSpecDrift = 0,
343
+ plan_only: PlanOnly = False,
344
+ verbose: Verbose = False,
345
+ seed: Seed = None,
346
+ temperature: Temperature = None,
347
+ no_cost_tracking: NoCost = False,
348
+ ) -> None:
349
+ """Plan workflows, write tests, run them against the API and triage every failure."""
350
+ choice = resolve_models(model, role_model)
351
+ _check_no_redact(no_redact, base_url)
352
+ check_host(
353
+ base_url,
354
+ allow_host,
355
+ allow_remote=allow_remote,
356
+ yes=yes or ci,
357
+ read_only=read_only,
358
+ planned_writes=_writes_in_spec(spec),
359
+ )
360
+ auth_config = _load_auth(auth)
361
+ _check_auth_env(auth_config)
362
+ tracked = _cost_tracked(no_cost_tracking)
363
+ _check_cost_limit(max_cost_usd, tracked)
364
+ outcome = _run_pipeline(
365
+ TestConfig(
366
+ spec_source=spec,
367
+ base_url=base_url,
368
+ workdir=Path.cwd(),
369
+ models=_build_models(choice, seed, temperature, tracked),
370
+ budget=Budget(
371
+ max_review_rounds=max_review_rounds,
372
+ max_fix_attempts=max_fix_attempts,
373
+ max_iterations=max_iterations,
374
+ max_steps=max_steps,
375
+ max_cost_usd=max_cost_usd,
376
+ ),
377
+ docs_dir=docs,
378
+ auth=auth_config,
379
+ reports_dir=report,
380
+ read_only=read_only,
381
+ no_redact=no_redact,
382
+ allowed_hosts=tuple(allow_host or ()),
383
+ plan_only=plan_only,
384
+ verbose=verbose,
385
+ cost_tracked=tracked,
386
+ )
387
+ )
388
+ _print_report(outcome)
389
+ if ci:
390
+ _ci_exit(outcome.results, max_api_bugs=ci_max_api_bugs, max_spec_drift=ci_max_spec_drift)
391
+ clean = outcome.run.stop_reason is StopReason.COMPLETED
392
+ if not clean or outcome.results.has_unexpected_failures:
393
+ raise typer.Exit(EXIT_FAILURES)
394
+
395
+
396
+ @app.command("plan")
397
+ def plan_command(
398
+ spec: Spec,
399
+ docs: Docs = None,
400
+ model: ModelOpt = None,
401
+ role_model: RoleModel = None,
402
+ max_review_rounds: ReviewRounds = 2,
403
+ max_steps: Steps = 200,
404
+ max_cost_usd: MaxCost = None,
405
+ verbose: Verbose = False,
406
+ seed: Seed = None,
407
+ temperature: Temperature = None,
408
+ no_cost_tracking: NoCost = False,
409
+ ) -> None:
410
+ """Write reviewable workflows/<id>.json files and stop."""
411
+ choice = resolve_models(model, role_model)
412
+ tracked = _cost_tracked(no_cost_tracking)
413
+ _check_cost_limit(max_cost_usd, tracked)
414
+ outcome = _run_pipeline(
415
+ TestConfig(
416
+ spec_source=spec,
417
+ base_url="http://localhost",
418
+ workdir=Path.cwd(),
419
+ models=_build_models(choice, seed, temperature, tracked),
420
+ budget=Budget(
421
+ max_review_rounds=max_review_rounds,
422
+ max_steps=max_steps,
423
+ max_cost_usd=max_cost_usd,
424
+ ),
425
+ docs_dir=docs,
426
+ plan_only=True,
427
+ verbose=verbose,
428
+ cost_tracked=tracked,
429
+ )
430
+ )
431
+ typer.echo(outcome.run.summary)
432
+ if not outcome.planned:
433
+ typer.echo("The spec hasn't changed, so the existing workflow files were kept.")
434
+ if outcome.run.stop_reason is not StopReason.COMPLETED:
435
+ raise typer.Exit(EXIT_FAILURES)
436
+
437
+
438
+ @app.command("run")
439
+ def run_command(
440
+ tests: Annotated[Path, typer.Argument(help="Folder of generated tests.")],
441
+ base_url: OptionalBaseUrl = None,
442
+ auth: Auth = None,
443
+ report: Report = None,
444
+ read_only: ReadOnly = False,
445
+ no_redact: NoRedact = False,
446
+ allow_host: AllowHost = None,
447
+ allow_remote: AllowRemote = False,
448
+ yes: Yes = False,
449
+ ci: Ci = False,
450
+ ci_max_api_bugs: CiApiBugs = 0,
451
+ ci_max_spec_drift: CiSpecDrift = 0,
452
+ verbose: Verbose = False,
453
+ ) -> None:
454
+ """Re-run existing tests without calling a model."""
455
+ if not tests.is_dir():
456
+ raise typer.BadParameter(f"no such folder: {tests}")
457
+ _check_no_redact(no_redact, base_url)
458
+ if base_url:
459
+ check_host(
460
+ base_url, allow_host, allow_remote=allow_remote, yes=yes or ci, read_only=read_only
461
+ )
462
+ auth_config = _load_auth(auth)
463
+ _check_auth_env(auth_config)
464
+ outcomes = run_tests(
465
+ tests,
466
+ base_url=base_url,
467
+ env_names=auth_env_names(auth_config),
468
+ read_only=read_only,
469
+ no_redact=no_redact,
470
+ allowed_hosts=allow_host or (),
471
+ )
472
+ if not outcomes:
473
+ typer.echo(f"No tests found in {tests}", err=True)
474
+ raise typer.Exit(EXIT_FAILURES)
475
+ for outcome in outcomes:
476
+ if outcome.looks_fixed:
477
+ typer.echo(f"LOOKS FIXED {outcome.workflow_id}: {outcome.known_issue}")
478
+ elif outcome.expected:
479
+ typer.echo(f"EXPECTED {outcome.workflow_id}: {outcome.known_issue}")
480
+ elif outcome.skipped:
481
+ typer.echo(f"SKIPPED {outcome.workflow_id}: read-only run")
482
+ else:
483
+ detail = "" if outcome.passed else f": {outcome.failing_assertion}"
484
+ typer.echo(f"{outcome.status.upper():<11} {outcome.workflow_id}{detail}")
485
+ passed = sum(outcome.passed for outcome in outcomes)
486
+ expected = sum(outcome.expected for outcome in outcomes)
487
+ skipped = sum(outcome.skipped for outcome in outcomes)
488
+ failed = len(outcomes) - passed - expected - skipped
489
+ extra = f", {skipped} skipped" if skipped else ""
490
+ typer.echo(f"{passed} passed, {expected} expected to fail{extra}, {failed} failed")
491
+ results = results_from_outcomes(outcomes, Path.cwd())
492
+ if report is not None:
493
+ write_reports(results, report)
494
+ if ci:
495
+ _ci_exit(results, max_api_bugs=ci_max_api_bugs, max_spec_drift=ci_max_spec_drift)
496
+ if failed:
497
+ raise typer.Exit(EXIT_FAILURES)
498
+
499
+
500
+ @app.command("drift")
501
+ def drift_command(
502
+ spec: Spec,
503
+ base_url: BaseUrl,
504
+ auth: Auth = None,
505
+ report: Report = None,
506
+ model: ModelOpt = None,
507
+ role_model: RoleModel = None,
508
+ max_steps: Steps = 200,
509
+ max_cost_usd: MaxCost = None,
510
+ read_only: ReadOnly = False,
511
+ no_redact: NoRedact = False,
512
+ allow_host: AllowHost = None,
513
+ allow_remote: AllowRemote = False,
514
+ yes: Yes = False,
515
+ ci: Ci = False,
516
+ ci_max_spec_drift: CiSpecDrift = 0,
517
+ verbose: Verbose = False,
518
+ seed: Seed = None,
519
+ temperature: Temperature = None,
520
+ ) -> None:
521
+ """Check only whether the live API still matches its spec."""
522
+ choice = resolve_models(model, role_model)
523
+ _check_no_redact(no_redact, base_url)
524
+ check_host(
525
+ base_url,
526
+ allow_host,
527
+ allow_remote=allow_remote,
528
+ yes=yes or ci,
529
+ read_only=read_only,
530
+ planned_writes=_writes_in_spec(spec),
531
+ )
532
+ auth_config = _load_auth(auth)
533
+ _check_auth_env(auth_config)
534
+ _check_cost_limit(max_cost_usd, _cost_tracked(False))
535
+ outcome = _run_with(
536
+ run_drift_pipeline,
537
+ TestConfig(
538
+ spec_source=spec,
539
+ base_url=base_url,
540
+ workdir=Path.cwd(),
541
+ models=_build_models(choice, seed, temperature, _cost_tracked(False)),
542
+ budget=Budget(max_steps=max_steps, max_cost_usd=max_cost_usd),
543
+ auth=auth_config,
544
+ reports_dir=report,
545
+ read_only=read_only,
546
+ no_redact=no_redact,
547
+ allowed_hosts=tuple(allow_host or ()),
548
+ verbose=verbose,
549
+ cost_tracked=_cost_tracked(False),
550
+ ),
551
+ )
552
+ _print_report(outcome)
553
+ if ci:
554
+ _ci_exit(outcome.results, max_api_bugs=10**9, max_spec_drift=ci_max_spec_drift)
555
+ drifted = any(f.label not in {None, Label.TEST_BUG} for f in outcome.results.findings)
556
+ if outcome.run.stop_reason is not StopReason.COMPLETED or drifted:
557
+ raise typer.Exit(EXIT_FAILURES)
558
+
559
+
560
+ @app.command("inspect")
561
+ def inspect_command(spec: Spec) -> None:
562
+ """Export the spec's resources, operations and producer-to-consumer links as JSON."""
563
+ try:
564
+ graph = build_graph(load_spec(spec))
565
+ except SpecError as exc:
566
+ typer.echo(f"Error: {exc}", err=True)
567
+ raise typer.Exit(2) from exc
568
+ typer.echo(json.dumps(graph.model_dump(by_alias=True), indent=2))
569
+
570
+
571
+ @app.command("report")
572
+ def report_command(
573
+ results: Annotated[Path, typer.Argument(help="results.json from an earlier run.")],
574
+ report: Report = None,
575
+ ) -> None:
576
+ """Write report.md and JUnit XML from an earlier run's results."""
577
+ if not results.is_file():
578
+ raise typer.BadParameter(f"no such file: {results}")
579
+ try:
580
+ run = load_json(RunResults, results)
581
+ except (ValueError, OSError) as exc:
582
+ raise typer.BadParameter(f"{results} can't be read: {exc}") from exc
583
+ for path in write_reports(run, report or Path("reports")):
584
+ typer.echo(f"wrote {path}")