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.
- workflowval/__init__.py +3 -0
- workflowval/cli.py +584 -0
- workflowval/gen/__init__.py +194 -0
- workflowval/gen/source.py +109 -0
- workflowval/gen/style.md +17 -0
- workflowval/gen/templates/.gitkeep +0 -0
- workflowval/gen/templates/conftest.py.tmpl +332 -0
- workflowval/loop/__init__.py +55 -0
- workflowval/loop/runner.py +488 -0
- workflowval/loop/stats.py +178 -0
- workflowval/loop/trace.py +29 -0
- workflowval/models/__init__.py +19 -0
- workflowval/models/base.py +68 -0
- workflowval/models/fake.py +55 -0
- workflowval/models/litellm_adapter.py +153 -0
- workflowval/pipeline.py +906 -0
- workflowval/planner/__init__.py +62 -0
- workflowval/prompts/__init__.py +34 -0
- workflowval/prompts/planner.md +37 -0
- workflowval/prompts/reviewer.md +30 -0
- workflowval/prompts/task_answer_review.md +14 -0
- workflowval/prompts/task_finding.md +17 -0
- workflowval/prompts/task_fix_test.md +6 -0
- workflowval/prompts/task_fix_workflow.md +7 -0
- workflowval/prompts/task_patch.md +9 -0
- workflowval/prompts/task_plan.md +13 -0
- workflowval/prompts/task_plan_gaps.md +6 -0
- workflowval/prompts/task_report.md +9 -0
- workflowval/prompts/task_review_plan.md +17 -0
- workflowval/prompts/task_review_results.md +19 -0
- workflowval/prompts/task_triage.md +9 -0
- workflowval/prompts/writer.md +11 -0
- workflowval/py.typed +0 -0
- workflowval/redact.py +97 -0
- workflowval/report/__init__.py +162 -0
- workflowval/report/curl.py +75 -0
- workflowval/schemas/__init__.py +78 -0
- workflowval/schemas/_base.py +43 -0
- workflowval/schemas/auth.py +66 -0
- workflowval/schemas/labels.py +31 -0
- workflowval/schemas/results.py +222 -0
- workflowval/schemas/review.py +94 -0
- workflowval/schemas/state.py +32 -0
- workflowval/schemas/stats.py +36 -0
- workflowval/schemas/workflow.py +148 -0
- workflowval/spec/__init__.py +38 -0
- workflowval/spec/graph.py +196 -0
- workflowval/spec/loader.py +102 -0
- workflowval/spec/validation.py +98 -0
- workflowval/testrun.py +159 -0
- workflowval/tools/__init__.py +6 -0
- workflowval/tools/base.py +49 -0
- workflowval/tools/registry.py +637 -0
- workflowval/triage/__init__.py +199 -0
- workflowval-0.1.0.dist-info/METADATA +215 -0
- workflowval-0.1.0.dist-info/RECORD +59 -0
- workflowval-0.1.0.dist-info/WHEEL +4 -0
- workflowval-0.1.0.dist-info/entry_points.txt +2 -0
- workflowval-0.1.0.dist-info/licenses/LICENSE +21 -0
workflowval/__init__.py
ADDED
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}")
|