assay-kit 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.
- assay/__init__.py +6 -0
- assay/cli/__init__.py +0 -0
- assay/cli/main.py +406 -0
- assay/config.py +149 -0
- assay/formatter/__init__.py +1 -0
- assay/formatter/formatter.py +93 -0
- assay/formatter/writer.py +34 -0
- assay/grain/__init__.py +0 -0
- assay/grain/detect.py +39 -0
- assay/ingest/__init__.py +3 -0
- assay/ingest/app.py +109 -0
- assay/keys/__init__.py +1 -0
- assay/keys/store.py +114 -0
- assay/runner/__init__.py +0 -0
- assay/runner/artifacts.py +73 -0
- assay/runner/runner.py +99 -0
- assay/schedule/__init__.py +0 -0
- assay/schedule/cron.py +29 -0
- assay/schedule/daemon.py +125 -0
- assay/schedule/loop.py +83 -0
- assay/schedule/store.py +98 -0
- assay/schemas/__init__.py +15 -0
- assay_kit-0.1.0.dist-info/METADATA +176 -0
- assay_kit-0.1.0.dist-info/RECORD +27 -0
- assay_kit-0.1.0.dist-info/WHEEL +5 -0
- assay_kit-0.1.0.dist-info/entry_points.txt +2 -0
- assay_kit-0.1.0.dist-info/top_level.txt +1 -0
assay/__init__.py
ADDED
assay/cli/__init__.py
ADDED
|
File without changes
|
assay/cli/main.py
ADDED
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
"""Assay CLI entrypoint."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional, cast
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from assay import __version__
|
|
12
|
+
from assay.config import AssayConfig, ConfigError, load_config
|
|
13
|
+
from assay.formatter.formatter import format_packet
|
|
14
|
+
from assay.formatter.writer import write_packet
|
|
15
|
+
from assay.keys.store import KeyStoreError, create_key, list_keys, revoke_key
|
|
16
|
+
from assay.runner import artifacts as _artifacts
|
|
17
|
+
from assay.runner import runner as _runner
|
|
18
|
+
from assay.schedule.cron import InvalidCronError, validate_cron
|
|
19
|
+
from assay.schedule.store import ScheduleStoreError, add_schedule, list_schedules, remove_schedule
|
|
20
|
+
|
|
21
|
+
app = typer.Typer(
|
|
22
|
+
help="Assay — independent verification layer for software projects.",
|
|
23
|
+
invoke_without_command=True,
|
|
24
|
+
)
|
|
25
|
+
schedule_app = typer.Typer(help="Manage scheduled test runs.")
|
|
26
|
+
key_app = typer.Typer(help="Manage API keys for the ingest endpoint.")
|
|
27
|
+
|
|
28
|
+
app.add_typer(schedule_app, name="schedule")
|
|
29
|
+
app.add_typer(key_app, name="key")
|
|
30
|
+
|
|
31
|
+
_NOT_IMPLEMENTED = "not implemented"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@app.callback()
|
|
35
|
+
def main(
|
|
36
|
+
ctx: typer.Context,
|
|
37
|
+
version: Optional[bool] = typer.Option( # noqa: UP007
|
|
38
|
+
None,
|
|
39
|
+
"--version",
|
|
40
|
+
help="Print version and exit.",
|
|
41
|
+
),
|
|
42
|
+
config: Optional[str] = typer.Option( # noqa: UP007
|
|
43
|
+
None,
|
|
44
|
+
"--config",
|
|
45
|
+
help="Override config file path.",
|
|
46
|
+
envvar="ASSAY_CONFIG",
|
|
47
|
+
),
|
|
48
|
+
verbose: bool = typer.Option(False, "--verbose", help="Enable verbose output."),
|
|
49
|
+
) -> None:
|
|
50
|
+
try:
|
|
51
|
+
ctx.obj = load_config(config)
|
|
52
|
+
except ConfigError as exc:
|
|
53
|
+
typer.echo(f"config error: {exc}", err=True)
|
|
54
|
+
raise typer.Exit(2) from exc
|
|
55
|
+
if version:
|
|
56
|
+
typer.echo(f"assay {__version__}")
|
|
57
|
+
raise typer.Exit(0)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
# run
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@app.command()
|
|
66
|
+
def run(
|
|
67
|
+
ctx: typer.Context,
|
|
68
|
+
target: Optional[str] = typer.Option(None, "--target", help="URL or target to test."), # noqa: UP007
|
|
69
|
+
suite: str = typer.Option("default", "--suite", help="Test suite name."),
|
|
70
|
+
output: Optional[str] = typer.Option(None, "--output", help="Output directory."), # noqa: UP007
|
|
71
|
+
task_id: Optional[str] = typer.Option(None, "--task-id", help="Grain task ID to tag this run."), # noqa: UP007
|
|
72
|
+
submit: bool = typer.Option(False, "--submit", help="Submit packet to Grain output path after run."),
|
|
73
|
+
) -> None:
|
|
74
|
+
"""Execute a test run using the Playwright + Docker runner."""
|
|
75
|
+
from assay.grain.detect import detect_task_id
|
|
76
|
+
|
|
77
|
+
if target is None:
|
|
78
|
+
typer.echo("error: --target is required", err=True)
|
|
79
|
+
raise typer.Exit(2)
|
|
80
|
+
|
|
81
|
+
config: AssayConfig = ctx.obj
|
|
82
|
+
output_dir = output or config.output.directory
|
|
83
|
+
image = config.runner.docker_image
|
|
84
|
+
|
|
85
|
+
# Resolve task_id: explicit flag > Grain auto-detect
|
|
86
|
+
effective_task_id = task_id or detect_task_id(config.grain.project_root or None)
|
|
87
|
+
if effective_task_id:
|
|
88
|
+
typer.echo(f"task_id: {effective_task_id}")
|
|
89
|
+
|
|
90
|
+
runner_result = _runner.run(target, suite=suite, output_dir=output_dir, image=image)
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
bundle = _artifacts.collect_artifacts(runner_result.output_dir, runner_result)
|
|
94
|
+
except _artifacts.ArtifactError as exc:
|
|
95
|
+
typer.echo(f"error reading artifacts: {exc}", err=True)
|
|
96
|
+
raise typer.Exit(1) from exc
|
|
97
|
+
|
|
98
|
+
typer.echo(f"outcome: {bundle.outcome}")
|
|
99
|
+
if bundle.error:
|
|
100
|
+
typer.echo(f"error: {bundle.error}", err=True)
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
packet = format_packet(bundle, task_id=effective_task_id)
|
|
104
|
+
# Copy screenshot to a stable verification_id-based name in the output dir
|
|
105
|
+
verification_id = str(packet["verification_id"])
|
|
106
|
+
if bundle.screenshot_path:
|
|
107
|
+
src = Path(bundle.screenshot_path)
|
|
108
|
+
if src.exists():
|
|
109
|
+
dest = Path(output_dir) / f"{verification_id}.png"
|
|
110
|
+
shutil.copy2(src, dest)
|
|
111
|
+
packet["artifact_refs"] = [str(dest)]
|
|
112
|
+
packet_path = write_packet(packet, output_dir)
|
|
113
|
+
typer.echo(f"packet: {packet_path}")
|
|
114
|
+
except Exception as exc:
|
|
115
|
+
typer.echo(f"error writing packet: {exc}", err=True)
|
|
116
|
+
raise typer.Exit(1) from exc
|
|
117
|
+
|
|
118
|
+
if submit:
|
|
119
|
+
_do_submit(str(packet_path), config)
|
|
120
|
+
|
|
121
|
+
if bundle.outcome == "pass":
|
|
122
|
+
raise typer.Exit(0)
|
|
123
|
+
elif bundle.outcome == "fail":
|
|
124
|
+
raise typer.Exit(3)
|
|
125
|
+
else:
|
|
126
|
+
raise typer.Exit(1)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _do_submit(packet_path: str, config: AssayConfig) -> None:
|
|
130
|
+
"""Copy a packet to the configured Grain output path."""
|
|
131
|
+
import json as _json
|
|
132
|
+
|
|
133
|
+
grain_output = config.grain.output_path
|
|
134
|
+
if not grain_output:
|
|
135
|
+
typer.echo("error: [grain] output_path not configured", err=True)
|
|
136
|
+
raise typer.Exit(1)
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
data = _json.loads(Path(packet_path).read_text())
|
|
140
|
+
except Exception as exc:
|
|
141
|
+
typer.echo(f"error reading packet: {exc}", err=True)
|
|
142
|
+
raise typer.Exit(1) from exc
|
|
143
|
+
|
|
144
|
+
import jsonschema # type: ignore[import-untyped]
|
|
145
|
+
|
|
146
|
+
from assay.schemas import ASSAY_PAYLOAD
|
|
147
|
+
try:
|
|
148
|
+
jsonschema.validate(instance=data, schema=ASSAY_PAYLOAD)
|
|
149
|
+
except jsonschema.ValidationError as exc:
|
|
150
|
+
typer.echo(f"error: packet schema invalid: {exc.message}", err=True)
|
|
151
|
+
raise typer.Exit(1) from exc
|
|
152
|
+
|
|
153
|
+
dest_dir = Path(grain_output)
|
|
154
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
155
|
+
dest = dest_dir / Path(packet_path).name
|
|
156
|
+
shutil.copy2(packet_path, dest)
|
|
157
|
+
typer.echo(f"submitted: {dest}")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# ---------------------------------------------------------------------------
|
|
161
|
+
# submit
|
|
162
|
+
# ---------------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@app.command()
|
|
166
|
+
def submit(
|
|
167
|
+
ctx: typer.Context,
|
|
168
|
+
packet: str = typer.Option(..., "--packet", help="Path to the packet JSON file to submit."),
|
|
169
|
+
) -> None:
|
|
170
|
+
"""Validate and submit a packet to the configured Grain output path."""
|
|
171
|
+
config: AssayConfig = ctx.obj
|
|
172
|
+
_do_submit(packet, config)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# ---------------------------------------------------------------------------
|
|
176
|
+
# serve
|
|
177
|
+
# ---------------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@app.command()
|
|
181
|
+
def serve(
|
|
182
|
+
ctx: typer.Context,
|
|
183
|
+
host: str = typer.Option("127.0.0.1", "--host", help="Host to bind."),
|
|
184
|
+
port: int = typer.Option(8000, "--port", help="Port to bind."),
|
|
185
|
+
) -> None:
|
|
186
|
+
"""Start the FastAPI ingest server."""
|
|
187
|
+
import uvicorn
|
|
188
|
+
|
|
189
|
+
from assay.ingest.app import app as ingest_app
|
|
190
|
+
|
|
191
|
+
config: AssayConfig = ctx.obj
|
|
192
|
+
ingest_app.state.key_store = config.keys.store
|
|
193
|
+
ingest_app.state.output_dir = config.output.directory
|
|
194
|
+
|
|
195
|
+
uvicorn.run(ingest_app, host=host, port=port)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
# ---------------------------------------------------------------------------
|
|
199
|
+
# report
|
|
200
|
+
# ---------------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@app.command()
|
|
204
|
+
def report(
|
|
205
|
+
output: str = typer.Option("./assay-output", "--output", help="Directory to read packets from."),
|
|
206
|
+
format: str = typer.Option("text", "--format", help="Output format: text or json."),
|
|
207
|
+
filter: Optional[str] = typer.Option(None, "--filter", help="Filter packets, e.g. outcome=fail."), # noqa: UP007
|
|
208
|
+
) -> None:
|
|
209
|
+
"""Display task packet summaries from the output directory."""
|
|
210
|
+
import json as _json
|
|
211
|
+
from pathlib import Path as _Path
|
|
212
|
+
|
|
213
|
+
out = _Path(output)
|
|
214
|
+
if not out.is_dir():
|
|
215
|
+
typer.echo(f"error: output directory not found: {output}", err=True)
|
|
216
|
+
raise typer.Exit(1)
|
|
217
|
+
|
|
218
|
+
packets = []
|
|
219
|
+
for path in sorted(out.glob("assay-*.json")):
|
|
220
|
+
try:
|
|
221
|
+
data: dict[str, object] = _json.loads(path.read_text())
|
|
222
|
+
except Exception:
|
|
223
|
+
continue
|
|
224
|
+
packets.append(data)
|
|
225
|
+
|
|
226
|
+
# Apply --filter key=value
|
|
227
|
+
if filter:
|
|
228
|
+
if "=" not in filter:
|
|
229
|
+
typer.echo("error: --filter must be in key=value form", err=True)
|
|
230
|
+
raise typer.Exit(2)
|
|
231
|
+
fkey, fval = filter.split("=", 1)
|
|
232
|
+
packets = [p for p in packets if str(p.get(fkey, "")) == fval]
|
|
233
|
+
|
|
234
|
+
if format == "json":
|
|
235
|
+
typer.echo(_json.dumps(packets, indent=2))
|
|
236
|
+
raise typer.Exit(0)
|
|
237
|
+
|
|
238
|
+
if not packets:
|
|
239
|
+
typer.echo("no packets found")
|
|
240
|
+
raise typer.Exit(0)
|
|
241
|
+
|
|
242
|
+
# Text table
|
|
243
|
+
col = "{:<36} {:<13} {:<8} {:<10} {:<10} {}"
|
|
244
|
+
typer.echo(col.format("verification_id", "outcome", "severity", "screenshot", "verified_at", "summary"))
|
|
245
|
+
typer.echo("-" * 120)
|
|
246
|
+
for p in packets:
|
|
247
|
+
vid = str(p.get("verification_id", ""))[:36]
|
|
248
|
+
outcome = str(p.get("outcome", ""))
|
|
249
|
+
severity = str(p.get("severity", ""))
|
|
250
|
+
refs = cast(list[object], p.get("artifact_refs", []))
|
|
251
|
+
has_screenshot = "yes" if any(str(r).endswith(".png") for r in refs) else "no"
|
|
252
|
+
verified_at = str(p.get("verified_at", ""))[:10]
|
|
253
|
+
summary = str(p.get("summary", ""))
|
|
254
|
+
typer.echo(col.format(vid, outcome, severity, has_screenshot, verified_at, summary))
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
# ---------------------------------------------------------------------------
|
|
258
|
+
# schedule subcommands
|
|
259
|
+
# ---------------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
@schedule_app.command("add")
|
|
263
|
+
def schedule_add(
|
|
264
|
+
ctx: typer.Context,
|
|
265
|
+
cron: str = typer.Option(..., "--cron", help="Cron expression (5-field)."),
|
|
266
|
+
suite: str = typer.Option("default", "--suite", help="Test suite name."),
|
|
267
|
+
target: Optional[str] = typer.Option(None, "--target", help="URL override (uses config default if omitted)."), # noqa: UP007
|
|
268
|
+
) -> None:
|
|
269
|
+
"""Register a new scheduled run."""
|
|
270
|
+
try:
|
|
271
|
+
validate_cron(cron)
|
|
272
|
+
except InvalidCronError as exc:
|
|
273
|
+
typer.echo(f"error: {exc}", err=True)
|
|
274
|
+
raise typer.Exit(2) from exc
|
|
275
|
+
|
|
276
|
+
config: AssayConfig = ctx.obj
|
|
277
|
+
try:
|
|
278
|
+
sid = add_schedule(config.schedule.store, cron, suite=suite, target=target)
|
|
279
|
+
except ScheduleStoreError as exc:
|
|
280
|
+
typer.echo(f"error: {exc}", err=True)
|
|
281
|
+
raise typer.Exit(1) from exc
|
|
282
|
+
typer.echo(f"schedule added: {sid}")
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
@schedule_app.command("list")
|
|
286
|
+
def schedule_list(ctx: typer.Context) -> None:
|
|
287
|
+
"""List all active schedules."""
|
|
288
|
+
config: AssayConfig = ctx.obj
|
|
289
|
+
try:
|
|
290
|
+
schedules = list_schedules(config.schedule.store)
|
|
291
|
+
except ScheduleStoreError as exc:
|
|
292
|
+
typer.echo(f"error: {exc}", err=True)
|
|
293
|
+
raise typer.Exit(1) from exc
|
|
294
|
+
if not schedules:
|
|
295
|
+
typer.echo("no schedules")
|
|
296
|
+
return
|
|
297
|
+
for s in schedules:
|
|
298
|
+
target = s["target"] or "(config default)"
|
|
299
|
+
last = s["last_run"] or "never"
|
|
300
|
+
typer.echo(f"{s['id']} {s['cron']} suite={s['suite']} target={target} last_run={last}")
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
@schedule_app.command("run")
|
|
304
|
+
def schedule_run(ctx: typer.Context) -> None:
|
|
305
|
+
"""Start the scheduler loop (foreground; Ctrl+C to stop)."""
|
|
306
|
+
from assay.schedule.loop import run_scheduler
|
|
307
|
+
|
|
308
|
+
config: AssayConfig = ctx.obj
|
|
309
|
+
run_scheduler(config)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
@schedule_app.command("remove")
|
|
313
|
+
def schedule_remove(
|
|
314
|
+
ctx: typer.Context,
|
|
315
|
+
schedule_id: str = typer.Argument(..., help="Schedule ID to remove."),
|
|
316
|
+
) -> None:
|
|
317
|
+
"""Remove a schedule by ID."""
|
|
318
|
+
config: AssayConfig = ctx.obj
|
|
319
|
+
try:
|
|
320
|
+
remove_schedule(config.schedule.store, schedule_id)
|
|
321
|
+
except ScheduleStoreError as exc:
|
|
322
|
+
typer.echo(f"error: {exc}", err=True)
|
|
323
|
+
raise typer.Exit(1) from exc
|
|
324
|
+
typer.echo(f"removed: {schedule_id}")
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
@schedule_app.command("start")
|
|
328
|
+
def schedule_start(ctx: typer.Context) -> None:
|
|
329
|
+
"""Start the scheduler as a background daemon."""
|
|
330
|
+
from assay.schedule.daemon import start
|
|
331
|
+
|
|
332
|
+
start(config_path=None)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
@schedule_app.command("stop")
|
|
336
|
+
def schedule_stop() -> None:
|
|
337
|
+
"""Stop the running background scheduler daemon."""
|
|
338
|
+
from assay.schedule.daemon import stop
|
|
339
|
+
|
|
340
|
+
stop()
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
@schedule_app.command("status")
|
|
344
|
+
def schedule_status() -> None:
|
|
345
|
+
"""Show whether the background scheduler daemon is running."""
|
|
346
|
+
from assay.schedule.daemon import status
|
|
347
|
+
|
|
348
|
+
info = status()
|
|
349
|
+
if info["running"]:
|
|
350
|
+
typer.echo(f"running (pid {info['pid']})")
|
|
351
|
+
else:
|
|
352
|
+
typer.echo("stopped")
|
|
353
|
+
typer.echo(f"log: {info['log_file']}")
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
# ---------------------------------------------------------------------------
|
|
357
|
+
# key subcommands
|
|
358
|
+
# ---------------------------------------------------------------------------
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
@key_app.command("create")
|
|
362
|
+
def key_create(
|
|
363
|
+
ctx: typer.Context,
|
|
364
|
+
name: Optional[str] = typer.Option(None, "--name", help="Label for the key."), # noqa: UP007
|
|
365
|
+
) -> None:
|
|
366
|
+
"""Generate a new API key."""
|
|
367
|
+
config: AssayConfig = ctx.obj
|
|
368
|
+
try:
|
|
369
|
+
raw = create_key(config.keys.store, label=name)
|
|
370
|
+
except KeyStoreError as exc:
|
|
371
|
+
typer.echo(f"error: {exc}", err=True)
|
|
372
|
+
raise typer.Exit(1) from exc
|
|
373
|
+
typer.echo(f"key: {raw}")
|
|
374
|
+
typer.echo("Save this key — it will not be shown again.")
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
@key_app.command("list")
|
|
378
|
+
def key_list(ctx: typer.Context) -> None:
|
|
379
|
+
"""List all API keys (IDs and labels only)."""
|
|
380
|
+
config: AssayConfig = ctx.obj
|
|
381
|
+
try:
|
|
382
|
+
keys = list_keys(config.keys.store)
|
|
383
|
+
except KeyStoreError as exc:
|
|
384
|
+
typer.echo(f"error: {exc}", err=True)
|
|
385
|
+
raise typer.Exit(1) from exc
|
|
386
|
+
if not keys:
|
|
387
|
+
typer.echo("no keys")
|
|
388
|
+
return
|
|
389
|
+
for k in keys:
|
|
390
|
+
status = "revoked" if k["revoked"] else "active"
|
|
391
|
+
typer.echo(f"{k['id']} {k['label']} {status} {k['created_at']}")
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
@key_app.command("revoke")
|
|
395
|
+
def key_revoke(
|
|
396
|
+
ctx: typer.Context,
|
|
397
|
+
key_id: str = typer.Argument(..., help="Key ID to revoke."),
|
|
398
|
+
) -> None:
|
|
399
|
+
"""Revoke an API key by ID."""
|
|
400
|
+
config: AssayConfig = ctx.obj
|
|
401
|
+
try:
|
|
402
|
+
revoke_key(config.keys.store, key_id)
|
|
403
|
+
except KeyStoreError as exc:
|
|
404
|
+
typer.echo(f"error: {exc}", err=True)
|
|
405
|
+
raise typer.Exit(1) from exc
|
|
406
|
+
typer.echo(f"revoked: {key_id}")
|
assay/config.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Assay configuration loader.
|
|
2
|
+
|
|
3
|
+
Reads assay.toml from (in priority order):
|
|
4
|
+
1. explicit path passed to load_config()
|
|
5
|
+
2. ./assay.toml (project-local)
|
|
6
|
+
3. ~/.assay/config.toml (global user)
|
|
7
|
+
4. built-in defaults (all fields optional)
|
|
8
|
+
|
|
9
|
+
Exit code 2 is expected when ConfigError propagates to the CLI layer.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import tomllib
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
_KNOWN_SECTIONS = {"project", "runner", "output", "serve", "keys", "schedule", "grain"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ConfigError(Exception):
|
|
22
|
+
"""Raised on config parse or validation failure."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class ProjectConfig:
|
|
27
|
+
name: str = "assay"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class RunnerConfig:
|
|
32
|
+
docker_image: str = "assay-playwright:latest"
|
|
33
|
+
timeout_seconds: int = 300
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class OutputConfig:
|
|
38
|
+
directory: str = "./assay-output"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class ServeConfig:
|
|
43
|
+
host: str = "127.0.0.1"
|
|
44
|
+
port: int = 8000
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class KeysConfig:
|
|
49
|
+
store: str = "~/.assay/keys.json"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class ScheduleConfig:
|
|
54
|
+
store: str = "~/.assay/schedules.json"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class GrainConfig:
|
|
59
|
+
project_root: str = ""
|
|
60
|
+
output_path: str = ""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class AssayConfig:
|
|
65
|
+
project: ProjectConfig = field(default_factory=ProjectConfig)
|
|
66
|
+
runner: RunnerConfig = field(default_factory=RunnerConfig)
|
|
67
|
+
output: OutputConfig = field(default_factory=OutputConfig)
|
|
68
|
+
serve: ServeConfig = field(default_factory=ServeConfig)
|
|
69
|
+
keys: KeysConfig = field(default_factory=KeysConfig)
|
|
70
|
+
schedule: ScheduleConfig = field(default_factory=ScheduleConfig)
|
|
71
|
+
grain: GrainConfig = field(default_factory=GrainConfig)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _resolve_path(override: str | None) -> Path | None:
|
|
75
|
+
if override is not None:
|
|
76
|
+
p = Path(override)
|
|
77
|
+
if not p.exists():
|
|
78
|
+
raise ConfigError(f"config file not found: {override}")
|
|
79
|
+
return p
|
|
80
|
+
local = Path("assay.toml")
|
|
81
|
+
if local.exists():
|
|
82
|
+
return local
|
|
83
|
+
global_ = Path.home() / ".assay" / "config.toml"
|
|
84
|
+
if global_.exists():
|
|
85
|
+
return global_
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _parse(raw: dict[str, object]) -> AssayConfig:
|
|
90
|
+
unknown = set(raw.keys()) - _KNOWN_SECTIONS
|
|
91
|
+
if unknown:
|
|
92
|
+
raise ConfigError(f"unknown config section(s): {', '.join(sorted(unknown))}")
|
|
93
|
+
|
|
94
|
+
def _section(key: str) -> dict[str, object]:
|
|
95
|
+
val = raw.get(key, {})
|
|
96
|
+
if not isinstance(val, dict):
|
|
97
|
+
raise ConfigError(f"config section [{key}] must be a table")
|
|
98
|
+
return val
|
|
99
|
+
|
|
100
|
+
proj = _section("project")
|
|
101
|
+
runner = _section("runner")
|
|
102
|
+
output = _section("output")
|
|
103
|
+
serve = _section("serve")
|
|
104
|
+
keys = _section("keys")
|
|
105
|
+
schedule = _section("schedule")
|
|
106
|
+
grain = _section("grain")
|
|
107
|
+
|
|
108
|
+
raw_timeout = runner.get("timeout_seconds", 300)
|
|
109
|
+
raw_port = serve.get("port", 8000)
|
|
110
|
+
if not isinstance(raw_timeout, int):
|
|
111
|
+
raise ConfigError(f"runner.timeout_seconds must be an integer, got {raw_timeout!r}")
|
|
112
|
+
if not isinstance(raw_port, int):
|
|
113
|
+
raise ConfigError(f"serve.port must be an integer, got {raw_port!r}")
|
|
114
|
+
|
|
115
|
+
return AssayConfig(
|
|
116
|
+
project=ProjectConfig(name=str(proj.get("name", "assay"))),
|
|
117
|
+
runner=RunnerConfig(
|
|
118
|
+
docker_image=str(runner.get("docker_image", "assay-playwright:latest")),
|
|
119
|
+
timeout_seconds=raw_timeout,
|
|
120
|
+
),
|
|
121
|
+
output=OutputConfig(directory=str(output.get("directory", "./assay-output"))),
|
|
122
|
+
serve=ServeConfig(
|
|
123
|
+
host=str(serve.get("host", "127.0.0.1")),
|
|
124
|
+
port=raw_port,
|
|
125
|
+
),
|
|
126
|
+
keys=KeysConfig(store=str(keys.get("store", "~/.assay/keys.json"))),
|
|
127
|
+
schedule=ScheduleConfig(store=str(schedule.get("store", "~/.assay/schedules.json"))),
|
|
128
|
+
grain=GrainConfig(
|
|
129
|
+
project_root=str(grain.get("project_root", "")),
|
|
130
|
+
output_path=str(grain.get("output_path", "")),
|
|
131
|
+
),
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def load_config(path: str | None = None) -> AssayConfig:
|
|
136
|
+
"""Load and return AssayConfig from the resolved config file path.
|
|
137
|
+
|
|
138
|
+
Returns all-defaults AssayConfig if no config file is found.
|
|
139
|
+
Raises ConfigError on file-not-found (when explicit path given), parse error,
|
|
140
|
+
or unknown section.
|
|
141
|
+
"""
|
|
142
|
+
resolved = _resolve_path(path)
|
|
143
|
+
if resolved is None:
|
|
144
|
+
return AssayConfig()
|
|
145
|
+
try:
|
|
146
|
+
raw = tomllib.loads(resolved.read_text())
|
|
147
|
+
except tomllib.TOMLDecodeError as exc:
|
|
148
|
+
raise ConfigError(f"invalid TOML in {resolved}: {exc}") from exc
|
|
149
|
+
return _parse(raw)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Assay formatter package — converts runner artifacts to Grain Sentinel payloads."""
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Formatter — converts ArtifactBundle or SDK IngestPayload to a Grain Sentinel payload dict."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from typing import TYPE_CHECKING, Optional
|
|
8
|
+
|
|
9
|
+
from assay.runner.artifacts import ArtifactBundle
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from assay.ingest.app import IngestPayload
|
|
13
|
+
|
|
14
|
+
_ISSUE_TYPE_MAP = {
|
|
15
|
+
"pass": "test_failure",
|
|
16
|
+
"fail": "test_failure",
|
|
17
|
+
"inconclusive": "test_failure",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
_SEVERITY_MAP = {
|
|
21
|
+
"pass": "info",
|
|
22
|
+
"fail": "error",
|
|
23
|
+
"inconclusive": "warning",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _summary(bundle: ArtifactBundle) -> str:
|
|
28
|
+
"""Build a human-readable one-line summary."""
|
|
29
|
+
if bundle.outcome == "pass":
|
|
30
|
+
return f"pass: {bundle.url}"
|
|
31
|
+
if bundle.error:
|
|
32
|
+
return f"{bundle.outcome}: {bundle.error}"
|
|
33
|
+
return f"{bundle.outcome}: {bundle.url or 'unknown url'}"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _verified_at(bundle: ArtifactBundle) -> str:
|
|
37
|
+
"""Return bundle timestamp or current UTC time in ISO 8601."""
|
|
38
|
+
if bundle.timestamp:
|
|
39
|
+
return bundle.timestamp
|
|
40
|
+
return datetime.now(tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def format_packet(
|
|
44
|
+
bundle: ArtifactBundle,
|
|
45
|
+
task_id: Optional[str] = None, # noqa: UP007
|
|
46
|
+
) -> dict[str, object]:
|
|
47
|
+
"""Convert an ArtifactBundle into a Grain Sentinel payload dict.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
bundle: Populated ArtifactBundle from collect_artifacts().
|
|
51
|
+
task_id: Grain TASK-#### ID being verified; None for standalone runs.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
Dict conforming to data_contracts.md §1 Grain Sentinel payload schema.
|
|
55
|
+
"""
|
|
56
|
+
artifact_refs: list[str] = [bundle.screenshot_path] if bundle.screenshot_path else []
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
"verification_id": str(uuid.uuid4()),
|
|
60
|
+
"task_id": task_id,
|
|
61
|
+
"issue_type": _ISSUE_TYPE_MAP.get(bundle.outcome, "test_failure"),
|
|
62
|
+
"severity": _SEVERITY_MAP.get(bundle.outcome, "warning"),
|
|
63
|
+
"outcome": bundle.outcome,
|
|
64
|
+
"summary": _summary(bundle),
|
|
65
|
+
"artifact_refs": artifact_refs,
|
|
66
|
+
"followup_candidates": [],
|
|
67
|
+
"verified_at": _verified_at(bundle),
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def format_sdk_packet(payload: "IngestPayload") -> dict[str, object]:
|
|
72
|
+
"""Convert a browser SDK IngestPayload into a Grain Sentinel payload dict.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
payload: Validated IngestPayload from the POST /ingest handler.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
Dict conforming to data_contracts.md §1 schema.
|
|
79
|
+
"""
|
|
80
|
+
comment = payload.user_comment or ""
|
|
81
|
+
summary = f"SDK capture: {payload.url}" + (f" — {comment}" if comment else "")
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
"verification_id": str(uuid.uuid4()),
|
|
85
|
+
"task_id": None,
|
|
86
|
+
"issue_type": "screenshot_evidence",
|
|
87
|
+
"severity": "info",
|
|
88
|
+
"outcome": "inconclusive",
|
|
89
|
+
"summary": summary,
|
|
90
|
+
"artifact_refs": [],
|
|
91
|
+
"followup_candidates": [],
|
|
92
|
+
"verified_at": payload.captured_at,
|
|
93
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Packet file writer — serialises a Grain Sentinel payload dict to disk."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def write_packet(packet: dict[str, object], output_dir: str) -> Path:
|
|
10
|
+
"""Write a Grain Sentinel payload dict to a JSON file in output_dir.
|
|
11
|
+
|
|
12
|
+
The filename is derived from the packet's verified_at and verification_id
|
|
13
|
+
fields so it is both human-sortable and globally unique.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
packet: Grain Sentinel payload dict from format_packet().
|
|
17
|
+
output_dir: Directory to write into. Created if it does not exist.
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
Path of the written file.
|
|
21
|
+
"""
|
|
22
|
+
out = Path(output_dir)
|
|
23
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
24
|
+
|
|
25
|
+
verified_at = str(packet.get("verified_at", ""))
|
|
26
|
+
verification_id = str(packet.get("verification_id", ""))
|
|
27
|
+
|
|
28
|
+
# Make verified_at filename-safe: drop colons and dots
|
|
29
|
+
ts_safe = verified_at.replace(":", "").replace(".", "").replace("-", "")
|
|
30
|
+
|
|
31
|
+
filename = f"assay-{ts_safe}-{verification_id}.json"
|
|
32
|
+
path = out / filename
|
|
33
|
+
path.write_text(json.dumps(packet))
|
|
34
|
+
return path
|
assay/grain/__init__.py
ADDED
|
File without changes
|