argospy 0.2.2__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.
- argos/case.py +166 -0
- argos/cli.py +351 -0
- argos/client.py +111 -0
- argos/duration.py +32 -0
- argos/pack.py +59 -0
- argos/paths.py +9 -0
- argos/report.py +127 -0
- argos/runner.py +156 -0
- argos/secrets.py +49 -0
- argos/select.py +33 -0
- argos/suite.py +97 -0
- argos/term.py +164 -0
- argospy-0.2.2.dist-info/METADATA +28 -0
- argospy-0.2.2.dist-info/RECORD +16 -0
- argospy-0.2.2.dist-info/WHEEL +4 -0
- argospy-0.2.2.dist-info/entry_points.txt +2 -0
argos/case.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any, Callable
|
|
5
|
+
|
|
6
|
+
ONCE = "once"
|
|
7
|
+
SOAK = "soak"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def case_slug(case_id: str) -> str:
|
|
11
|
+
return case_id.replace(":", "-")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def run_slug(ids: list[str]) -> str:
|
|
15
|
+
if not ids:
|
|
16
|
+
return "empty"
|
|
17
|
+
if len(ids) == 1:
|
|
18
|
+
return case_slug(ids[0])
|
|
19
|
+
groups = {part.split(":", 1)[0] if ":" in part else part for part in ids}
|
|
20
|
+
if len(groups) == 1:
|
|
21
|
+
return next(iter(groups))
|
|
22
|
+
if len(ids) <= 3:
|
|
23
|
+
return "+".join(case_slug(i) for i in ids)
|
|
24
|
+
return f"{len(ids)}-cases"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class Spec:
|
|
29
|
+
id: str
|
|
30
|
+
title: str
|
|
31
|
+
group: str
|
|
32
|
+
tags: tuple[str, ...] = ()
|
|
33
|
+
modes: tuple[str, ...] = (ONCE,)
|
|
34
|
+
pack: str = ""
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def slug(self) -> str:
|
|
38
|
+
return case_slug(self.id)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class Step:
|
|
43
|
+
name: str
|
|
44
|
+
status: str
|
|
45
|
+
detail: str = ""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class Result:
|
|
50
|
+
spec: Spec
|
|
51
|
+
status: str
|
|
52
|
+
elapsed_s: float
|
|
53
|
+
error: str = ""
|
|
54
|
+
metrics: dict[str, Any] = field(default_factory=dict)
|
|
55
|
+
steps: list[Step] = field(default_factory=list)
|
|
56
|
+
notes: list[str] = field(default_factory=list)
|
|
57
|
+
dest: str = ""
|
|
58
|
+
iteration: int = 0
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Skip(Exception):
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class Fail(Exception):
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class Context:
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
spec: Spec,
|
|
73
|
+
dest: Path,
|
|
74
|
+
emit: Callable[[dict], None],
|
|
75
|
+
*,
|
|
76
|
+
iteration: int = 0,
|
|
77
|
+
run_dest: Path | None = None,
|
|
78
|
+
mode: str = ONCE,
|
|
79
|
+
):
|
|
80
|
+
self.spec = spec
|
|
81
|
+
self.dest = dest
|
|
82
|
+
self.dest.mkdir(parents=True, exist_ok=True)
|
|
83
|
+
self.iteration = iteration
|
|
84
|
+
self.run_dest = run_dest or dest.parent
|
|
85
|
+
self.mode = mode
|
|
86
|
+
self.metrics: dict[str, Any] = {}
|
|
87
|
+
self.steps: list[Step] = []
|
|
88
|
+
self.notes: list[str] = []
|
|
89
|
+
self._emit = emit
|
|
90
|
+
self.write(
|
|
91
|
+
"case.json",
|
|
92
|
+
{
|
|
93
|
+
"id": spec.id,
|
|
94
|
+
"slug": spec.slug,
|
|
95
|
+
"title": spec.title,
|
|
96
|
+
"group": spec.group,
|
|
97
|
+
"pack": spec.pack,
|
|
98
|
+
"tags": list(spec.tags),
|
|
99
|
+
"modes": list(spec.modes),
|
|
100
|
+
},
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
def metric(self, key: str, value: Any) -> None:
|
|
104
|
+
self.metrics[key] = value
|
|
105
|
+
self._dump()
|
|
106
|
+
self._emit({"type": "metric", "id": self.spec.id, "key": key, "value": value})
|
|
107
|
+
|
|
108
|
+
def note(self, message: str) -> None:
|
|
109
|
+
self.notes.append(message)
|
|
110
|
+
self._emit({"type": "note", "id": self.spec.id, "message": message})
|
|
111
|
+
|
|
112
|
+
def step(self, name: str, status: str = "ok", detail: str = "") -> None:
|
|
113
|
+
row = Step(name=name, status=status, detail=detail)
|
|
114
|
+
for i, existing in enumerate(self.steps):
|
|
115
|
+
if existing.name == name:
|
|
116
|
+
self.steps[i] = row
|
|
117
|
+
break
|
|
118
|
+
else:
|
|
119
|
+
self.steps.append(row)
|
|
120
|
+
self._dump()
|
|
121
|
+
self._emit({"type": "step", "id": self.spec.id, "name": name, "status": status, "detail": detail})
|
|
122
|
+
|
|
123
|
+
def check(self, ok: bool, message: str) -> None:
|
|
124
|
+
if not ok:
|
|
125
|
+
raise Fail(message)
|
|
126
|
+
|
|
127
|
+
def skip(self, message: str) -> None:
|
|
128
|
+
raise Skip(message)
|
|
129
|
+
|
|
130
|
+
def fail(self, message: str) -> None:
|
|
131
|
+
raise Fail(message)
|
|
132
|
+
|
|
133
|
+
def own(self, resource_id: str, catalog_id: str = "") -> None:
|
|
134
|
+
path = self.run_dest / "owned.json"
|
|
135
|
+
rows: list[dict[str, str]] = []
|
|
136
|
+
if path.exists():
|
|
137
|
+
raw = json.loads(path.read_text())
|
|
138
|
+
if isinstance(raw, list):
|
|
139
|
+
rows = [row for row in raw if isinstance(row, dict)]
|
|
140
|
+
if any(row.get("id") == resource_id for row in rows):
|
|
141
|
+
return
|
|
142
|
+
rows.append({"id": resource_id, "catalog_id": catalog_id})
|
|
143
|
+
path.write_text(json.dumps(rows, indent=2, ensure_ascii=False) + "\n")
|
|
144
|
+
|
|
145
|
+
def write(self, name: str, obj: Any) -> Path:
|
|
146
|
+
path = self.dest / name
|
|
147
|
+
if isinstance(obj, (bytes, bytearray)):
|
|
148
|
+
path.write_bytes(obj)
|
|
149
|
+
elif isinstance(obj, str):
|
|
150
|
+
path.write_text(obj)
|
|
151
|
+
else:
|
|
152
|
+
path.write_text(json.dumps(obj, indent=2, ensure_ascii=False) + "\n")
|
|
153
|
+
return path
|
|
154
|
+
|
|
155
|
+
def _dump(self) -> None:
|
|
156
|
+
self.write(
|
|
157
|
+
"progress.json",
|
|
158
|
+
{
|
|
159
|
+
"id": self.spec.id,
|
|
160
|
+
"slug": self.spec.slug,
|
|
161
|
+
"title": self.spec.title,
|
|
162
|
+
"metrics": self.metrics,
|
|
163
|
+
"steps": [s.__dict__ for s in self.steps],
|
|
164
|
+
"notes": self.notes,
|
|
165
|
+
},
|
|
166
|
+
)
|
argos/cli.py
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import json
|
|
3
|
+
import socket
|
|
4
|
+
import sys
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
import webbrowser
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from argos.case import ONCE, SOAK, run_slug
|
|
11
|
+
from argos.client import Client, DashError, dash_token, dash_url, push_dir
|
|
12
|
+
from argos.duration import parse_duration
|
|
13
|
+
from argos.paths import out_root
|
|
14
|
+
from argos.report import write_reports
|
|
15
|
+
from argos.runner import write_events
|
|
16
|
+
from argos.secrets import load_secrets
|
|
17
|
+
from argos.select import select
|
|
18
|
+
from argos.suite import all_cases, all_packs, apply_env, env_names, execute, pack_named, packs_of
|
|
19
|
+
from argos.term import DIM, RESET, Progress, fmt_dur
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def list_packs() -> int:
|
|
23
|
+
packs = all_packs()
|
|
24
|
+
print(f"{'ID':<16} {'ENVS':<24} TITLE")
|
|
25
|
+
for pack in packs:
|
|
26
|
+
print(f"{pack.id:<16} {','.join(pack.envs):<24} {pack.title}")
|
|
27
|
+
print(f"\n{len(packs)} packs")
|
|
28
|
+
return 0
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def list_cases(queries: list[str], mode: str | None) -> int:
|
|
32
|
+
catalog = all_cases()
|
|
33
|
+
specs = select([c.spec for c in catalog], queries)
|
|
34
|
+
if mode:
|
|
35
|
+
specs = [s for s in specs if mode in s.modes]
|
|
36
|
+
print(f"{'ID':<32} {'PACK':<10} {'SLUG':<28} {'GROUP':<10} {'MODES':<12} {'TAGS':<22} TITLE")
|
|
37
|
+
for spec in specs:
|
|
38
|
+
tags = ",".join(spec.tags)
|
|
39
|
+
modes = ",".join(spec.modes)
|
|
40
|
+
print(f"{spec.id:<32} {spec.pack:<10} {spec.slug:<28} {spec.group:<10} {modes:<12} {tags:<22} {spec.title}")
|
|
41
|
+
print(f"\n{len(specs)} cases")
|
|
42
|
+
print()
|
|
43
|
+
print("once argos run <id>")
|
|
44
|
+
print("soak argos run <id> --soak --for 8h")
|
|
45
|
+
print("env argos run <id> --env <name>")
|
|
46
|
+
print("pack argos list pack:<id>")
|
|
47
|
+
print("push argos run <id> --push")
|
|
48
|
+
return 0
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _env_help() -> str:
|
|
52
|
+
names = env_names()
|
|
53
|
+
if not names:
|
|
54
|
+
return "named environment from the selected packs"
|
|
55
|
+
return "e2e target: " + " / ".join(names)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _resolve_stack_pack(pack_id: str | None):
|
|
59
|
+
candidates = [pack for pack in all_packs() if pack.stack_up]
|
|
60
|
+
if pack_id:
|
|
61
|
+
pack = pack_named(pack_id)
|
|
62
|
+
if not pack:
|
|
63
|
+
raise RuntimeError(f"unknown pack {pack_id}")
|
|
64
|
+
if not pack.stack_up:
|
|
65
|
+
raise RuntimeError(f"pack {pack_id} has no stack")
|
|
66
|
+
return pack
|
|
67
|
+
if len(candidates) == 1:
|
|
68
|
+
return candidates[0]
|
|
69
|
+
if not candidates:
|
|
70
|
+
raise RuntimeError("no pack defines a stack")
|
|
71
|
+
names = " ".join(pack.id for pack in candidates)
|
|
72
|
+
raise RuntimeError(f"choose a pack: argos up <{names}>")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _public_event(event: dict) -> dict:
|
|
76
|
+
row = {k: v for k, v in event.items() if k != "result"}
|
|
77
|
+
if "result" in event:
|
|
78
|
+
result = event["result"]
|
|
79
|
+
row["status"] = result.status
|
|
80
|
+
row["elapsed_s"] = result.elapsed_s
|
|
81
|
+
row["error"] = result.error
|
|
82
|
+
row["iteration"] = result.iteration
|
|
83
|
+
return row
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def run_cases(
|
|
87
|
+
queries: list[str],
|
|
88
|
+
*,
|
|
89
|
+
soak: bool,
|
|
90
|
+
duration: str,
|
|
91
|
+
pause: str,
|
|
92
|
+
fail_fast: bool,
|
|
93
|
+
env: str | None,
|
|
94
|
+
push: bool,
|
|
95
|
+
dash: str,
|
|
96
|
+
) -> int:
|
|
97
|
+
if not queries:
|
|
98
|
+
print("select at least one case. examples:", file=sys.stderr)
|
|
99
|
+
print(" argos list", file=sys.stderr)
|
|
100
|
+
print(" argos list pack:<id>", file=sys.stderr)
|
|
101
|
+
print(" argos run unit", file=sys.stderr)
|
|
102
|
+
return 2
|
|
103
|
+
catalog = all_cases()
|
|
104
|
+
picked = select([c.spec for c in catalog], queries)
|
|
105
|
+
if not picked:
|
|
106
|
+
print("no cases matched", " ".join(queries), file=sys.stderr)
|
|
107
|
+
return 2
|
|
108
|
+
by_id = {c.spec.id: c for c in catalog}
|
|
109
|
+
chosen = [by_id[s.id] for s in picked]
|
|
110
|
+
mode = SOAK if soak else ONCE
|
|
111
|
+
unsupported = [c.spec.id for c in chosen if mode not in c.spec.modes]
|
|
112
|
+
if unsupported:
|
|
113
|
+
print(f"{mode} not supported:", ", ".join(unsupported), file=sys.stderr)
|
|
114
|
+
print("see: argos list", file=sys.stderr)
|
|
115
|
+
return 2
|
|
116
|
+
involved = packs_of(chosen)
|
|
117
|
+
has_e2e = any("e2e" in c.spec.tags for c in chosen)
|
|
118
|
+
if has_e2e and not env:
|
|
119
|
+
names = [name for pack in involved for name in pack.envs]
|
|
120
|
+
seen: list[str] = []
|
|
121
|
+
for name in names:
|
|
122
|
+
if name not in seen:
|
|
123
|
+
seen.append(name)
|
|
124
|
+
print("e2e cases require --env " + (" or ".join(seen) if seen else "<pack env>"), file=sys.stderr)
|
|
125
|
+
return 2
|
|
126
|
+
if env:
|
|
127
|
+
try:
|
|
128
|
+
applied = apply_env(env, involved)
|
|
129
|
+
except ValueError as exc:
|
|
130
|
+
print(exc, file=sys.stderr)
|
|
131
|
+
return 2
|
|
132
|
+
else:
|
|
133
|
+
applied = ""
|
|
134
|
+
|
|
135
|
+
stamp = time.strftime("%Y%m%d-%H%M%S")
|
|
136
|
+
slug = run_slug([c.spec.id for c in chosen])
|
|
137
|
+
dest = out_root() / f"{stamp}__{slug}"
|
|
138
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
139
|
+
run_meta = {
|
|
140
|
+
"started": stamp,
|
|
141
|
+
"slug": slug,
|
|
142
|
+
"mode": mode,
|
|
143
|
+
"env": applied,
|
|
144
|
+
"packs": [pack.id for pack in involved],
|
|
145
|
+
"queries": queries,
|
|
146
|
+
"runner": socket.gethostname(),
|
|
147
|
+
"cases": [
|
|
148
|
+
{
|
|
149
|
+
"id": c.spec.id,
|
|
150
|
+
"slug": c.spec.slug,
|
|
151
|
+
"title": c.spec.title,
|
|
152
|
+
"group": c.spec.group,
|
|
153
|
+
"pack": c.spec.pack,
|
|
154
|
+
}
|
|
155
|
+
for c in chosen
|
|
156
|
+
],
|
|
157
|
+
}
|
|
158
|
+
dest.joinpath("run.json").write_text(json.dumps(run_meta, indent=2, ensure_ascii=False) + "\n")
|
|
159
|
+
events = dest / "events.jsonl"
|
|
160
|
+
progress = Progress([c.spec for c in chosen], str(dest))
|
|
161
|
+
lock = threading.Lock()
|
|
162
|
+
remote: Client | None = None
|
|
163
|
+
remote_id = ""
|
|
164
|
+
pending: list[dict] = []
|
|
165
|
+
|
|
166
|
+
if push:
|
|
167
|
+
token = dash_token()
|
|
168
|
+
if not token:
|
|
169
|
+
print("ARGOS_TOKEN is required for --push", file=sys.stderr)
|
|
170
|
+
return 2
|
|
171
|
+
remote = Client(dash_url(dash), token)
|
|
172
|
+
try:
|
|
173
|
+
created = remote.create_run(run_meta)
|
|
174
|
+
except DashError as exc:
|
|
175
|
+
print(exc, file=sys.stderr)
|
|
176
|
+
return 2
|
|
177
|
+
remote_id = str(created["id"])
|
|
178
|
+
print(f"dash {created.get('url') or remote.browse_url(remote_id)}")
|
|
179
|
+
|
|
180
|
+
def flush_remote() -> None:
|
|
181
|
+
if not remote or not remote_id or not pending:
|
|
182
|
+
return
|
|
183
|
+
batch = pending[:]
|
|
184
|
+
pending.clear()
|
|
185
|
+
remote.post_events(remote_id, batch)
|
|
186
|
+
|
|
187
|
+
def emit(event: dict) -> None:
|
|
188
|
+
with lock:
|
|
189
|
+
write_events(events, event)
|
|
190
|
+
progress.on_event(event)
|
|
191
|
+
if remote and remote_id:
|
|
192
|
+
pending.append(_public_event(event))
|
|
193
|
+
if len(pending) >= 20:
|
|
194
|
+
try:
|
|
195
|
+
flush_remote()
|
|
196
|
+
except DashError as exc:
|
|
197
|
+
print(exc, file=sys.stderr)
|
|
198
|
+
|
|
199
|
+
print(f"run {dest}")
|
|
200
|
+
print(f"env {applied or '-'} mode {mode} selected {len(chosen)}: " + " ".join(c.spec.id for c in chosen))
|
|
201
|
+
for pack in involved:
|
|
202
|
+
pack_cases = [c for c in chosen if c.spec.pack == pack.id]
|
|
203
|
+
if pack.needs_stack and pack.needs_stack(pack_cases):
|
|
204
|
+
try:
|
|
205
|
+
pack.stack_up()
|
|
206
|
+
except RuntimeError as exc:
|
|
207
|
+
print(exc, file=sys.stderr)
|
|
208
|
+
return 2
|
|
209
|
+
started = time.time()
|
|
210
|
+
results = []
|
|
211
|
+
try:
|
|
212
|
+
if not soak:
|
|
213
|
+
results = execute(chosen, dest, emit, iteration=0, mode=mode)
|
|
214
|
+
else:
|
|
215
|
+
budget = parse_duration(duration)
|
|
216
|
+
gap = parse_duration(pause) if pause else 0.0
|
|
217
|
+
deadline = started + budget
|
|
218
|
+
print(f"soak {fmt_dur(budget).strip()} pause {fmt_dur(gap).strip()} fail_fast={fail_fast}")
|
|
219
|
+
n = 0
|
|
220
|
+
while time.time() < deadline:
|
|
221
|
+
n += 1
|
|
222
|
+
left = deadline - time.time()
|
|
223
|
+
emit({"type": "note", "id": chosen[0].spec.id, "message": f"soak iter {n} left {fmt_dur(left).strip()}"})
|
|
224
|
+
batch = execute(chosen, dest, emit, iteration=n, mode=mode)
|
|
225
|
+
results.extend(batch)
|
|
226
|
+
if fail_fast and any(r.status == "fail" for r in batch):
|
|
227
|
+
break
|
|
228
|
+
if time.time() + gap >= deadline:
|
|
229
|
+
break
|
|
230
|
+
if gap > 0:
|
|
231
|
+
time.sleep(gap)
|
|
232
|
+
except KeyboardInterrupt:
|
|
233
|
+
print("interrupted", file=sys.stderr)
|
|
234
|
+
if soak:
|
|
235
|
+
for pack in involved:
|
|
236
|
+
if pack.soak_teardown:
|
|
237
|
+
pack.soak_teardown(dest, emit, [c for c in chosen if c.spec.pack == pack.id])
|
|
238
|
+
write_reports(dest, results, stamp, wall_s=time.time() - started)
|
|
239
|
+
progress.summary()
|
|
240
|
+
print(f"report {dest / 'report.html'}")
|
|
241
|
+
print(f"json {dest / 'report.json'}")
|
|
242
|
+
print(f"md {dest / 'report.md'}")
|
|
243
|
+
if remote and remote_id:
|
|
244
|
+
try:
|
|
245
|
+
with lock:
|
|
246
|
+
flush_remote()
|
|
247
|
+
report = json.loads((dest / "report.json").read_text())
|
|
248
|
+
status = "fail" if any(r.status == "fail" for r in results) else "pass"
|
|
249
|
+
if not results:
|
|
250
|
+
status = "interrupted"
|
|
251
|
+
remote.finish(remote_id, report, status)
|
|
252
|
+
print(f"dash {remote.browse_url(remote_id)}")
|
|
253
|
+
except DashError as exc:
|
|
254
|
+
print(exc, file=sys.stderr)
|
|
255
|
+
if sys.stdout.isatty():
|
|
256
|
+
print(f"{DIM}open the HTML report for the visual summary{RESET}")
|
|
257
|
+
return 0 if results and all(r.status != "fail" for r in results) else 1
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def cmd_push(path: str, dash: str) -> int:
|
|
261
|
+
dest = Path(path)
|
|
262
|
+
if not dest.is_dir():
|
|
263
|
+
print(f"not a run directory: {dest}", file=sys.stderr)
|
|
264
|
+
return 2
|
|
265
|
+
token = dash_token()
|
|
266
|
+
if not token:
|
|
267
|
+
print("ARGOS_TOKEN is required", file=sys.stderr)
|
|
268
|
+
return 2
|
|
269
|
+
client = Client(dash_url(dash), token)
|
|
270
|
+
try:
|
|
271
|
+
run_id = push_dir(client, dest)
|
|
272
|
+
except DashError as exc:
|
|
273
|
+
print(exc, file=sys.stderr)
|
|
274
|
+
return 2
|
|
275
|
+
print(client.browse_url(run_id))
|
|
276
|
+
return 0
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def cmd_dash(dash: str, no_open: bool) -> int:
|
|
280
|
+
url = dash_url(dash)
|
|
281
|
+
print(url)
|
|
282
|
+
if not no_open:
|
|
283
|
+
webbrowser.open(url)
|
|
284
|
+
return 0
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def main(argv: list[str] | None = None) -> int:
|
|
288
|
+
load_secrets()
|
|
289
|
+
parser = argparse.ArgumentParser(prog="argos", description="once / soak test framework")
|
|
290
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
291
|
+
sub.add_parser("packs", help="list installed packs")
|
|
292
|
+
p_list = sub.add_parser("list", help="list cases and how to run them")
|
|
293
|
+
p_list.add_argument("query", nargs="*", help="id, pack:, group:, tag:, mode:once|soak, or glob")
|
|
294
|
+
p_list.add_argument("--mode", choices=(ONCE, SOAK), help="only cases that support this mode")
|
|
295
|
+
p_run = sub.add_parser("run", help="run selected cases once, or soak until --for")
|
|
296
|
+
p_run.add_argument("query", nargs="*", help="id, pack:, group:, tag:, mode:, or glob")
|
|
297
|
+
p_run.add_argument("--soak", action="store_true", help="repeat until --for (k6-style constant duration)")
|
|
298
|
+
p_run.add_argument("--for", dest="duration", default="8h", help="soak budget (8h, 90m, 1h30m). default 8h")
|
|
299
|
+
p_run.add_argument("--pause", default="0s", help="sleep between soak iterations")
|
|
300
|
+
p_run.add_argument("--fail-fast", action="store_true", help="stop soak on first failed iteration")
|
|
301
|
+
p_run.add_argument("--env", choices=env_names() or None, help=_env_help())
|
|
302
|
+
p_run.add_argument("--push", action="store_true", help="stream this run to dash")
|
|
303
|
+
p_run.add_argument("--dash", default="", help="dash base URL (default ARGOS_DASH_URL or https://argos.saidc.ai)")
|
|
304
|
+
p_up = sub.add_parser("up", help="start a pack's local stack")
|
|
305
|
+
p_up.add_argument("pack", nargs="?", help="pack id (default: the only pack that has a stack)")
|
|
306
|
+
p_down = sub.add_parser("down", help="stop a pack's local stack")
|
|
307
|
+
p_down.add_argument("pack", nargs="?", help="pack id (default: the only pack that has a stack)")
|
|
308
|
+
p_push = sub.add_parser("push", help="upload a finished local run directory")
|
|
309
|
+
p_push.add_argument("path", help="out/<stamp>__<slug> directory")
|
|
310
|
+
p_push.add_argument("--dash", default="", help="dash base URL")
|
|
311
|
+
p_dash = sub.add_parser("dash", help="open the configured dash")
|
|
312
|
+
p_dash.add_argument("--dash", default="", help="dash base URL")
|
|
313
|
+
p_dash.add_argument("--no-open", action="store_true", help="print the URL only")
|
|
314
|
+
args = parser.parse_args(argv)
|
|
315
|
+
if args.cmd == "packs":
|
|
316
|
+
return list_packs()
|
|
317
|
+
if args.cmd == "list":
|
|
318
|
+
return list_cases(args.query, args.mode)
|
|
319
|
+
if args.cmd == "push":
|
|
320
|
+
return cmd_push(args.path, args.dash)
|
|
321
|
+
if args.cmd == "dash":
|
|
322
|
+
return cmd_dash(args.dash, args.no_open)
|
|
323
|
+
if args.cmd in {"up", "down"}:
|
|
324
|
+
try:
|
|
325
|
+
pack = _resolve_stack_pack(args.pack)
|
|
326
|
+
if pack.envs:
|
|
327
|
+
apply_env(pack.envs[0], [pack])
|
|
328
|
+
if args.cmd == "up":
|
|
329
|
+
pack.stack_up()
|
|
330
|
+
else:
|
|
331
|
+
if not pack.stack_down:
|
|
332
|
+
raise RuntimeError(f"pack {pack.id} has no stack down")
|
|
333
|
+
pack.stack_down()
|
|
334
|
+
except RuntimeError as exc:
|
|
335
|
+
print(exc, file=sys.stderr)
|
|
336
|
+
return 2
|
|
337
|
+
return 0
|
|
338
|
+
return run_cases(
|
|
339
|
+
args.query,
|
|
340
|
+
soak=args.soak,
|
|
341
|
+
duration=args.duration,
|
|
342
|
+
pause=args.pause,
|
|
343
|
+
fail_fast=args.fail_fast,
|
|
344
|
+
env=args.env,
|
|
345
|
+
push=args.push,
|
|
346
|
+
dash=args.dash,
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
if __name__ == "__main__":
|
|
351
|
+
raise SystemExit(main())
|
argos/client.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import urllib.error
|
|
4
|
+
import urllib.request
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
from urllib.parse import urljoin
|
|
8
|
+
|
|
9
|
+
DEFAULT_DASH = "https://argos.saidc.ai"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def dash_url(override: str = "") -> str:
|
|
13
|
+
raw = (override or os.environ.get("ARGOS_DASH_URL") or DEFAULT_DASH).strip()
|
|
14
|
+
return raw.rstrip("/")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def dash_token() -> str:
|
|
18
|
+
return (os.environ.get("ARGOS_TOKEN") or "").strip()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class DashError(RuntimeError):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Client:
|
|
26
|
+
def __init__(self, base: str, token: str) -> None:
|
|
27
|
+
self.base = base.rstrip("/")
|
|
28
|
+
self.token = token
|
|
29
|
+
|
|
30
|
+
def create_run(self, body: dict[str, Any]) -> dict[str, Any]:
|
|
31
|
+
return self._json("POST", "/api/runs", body)
|
|
32
|
+
|
|
33
|
+
def post_events(self, run_id: str, events: list[dict[str, Any]]) -> dict[str, Any]:
|
|
34
|
+
return self._json("POST", f"/api/runs/{run_id}/events", {"events": events})
|
|
35
|
+
|
|
36
|
+
def finish(self, run_id: str, report: dict[str, Any], status: str) -> dict[str, Any]:
|
|
37
|
+
return self._json("POST", f"/api/runs/{run_id}/finish", {"report": report, "status": status})
|
|
38
|
+
|
|
39
|
+
def upload_file(self, run_id: str, rel: str, text: str) -> dict[str, Any]:
|
|
40
|
+
return self._json("POST", f"/api/runs/{run_id}/files", {"path": rel, "text": text})
|
|
41
|
+
|
|
42
|
+
def browse_url(self, run_id: str) -> str:
|
|
43
|
+
return f"{self.base}/runs/{run_id}"
|
|
44
|
+
|
|
45
|
+
def _json(self, method: str, path: str, body: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
46
|
+
data = None if body is None else json.dumps(body, ensure_ascii=False, default=str).encode("utf-8")
|
|
47
|
+
req = urllib.request.Request(
|
|
48
|
+
urljoin(self.base + "/", path.lstrip("/")),
|
|
49
|
+
data=data,
|
|
50
|
+
method=method,
|
|
51
|
+
headers={
|
|
52
|
+
"Accept": "application/json",
|
|
53
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
54
|
+
"Authorization": f"Bearer {self.token}",
|
|
55
|
+
},
|
|
56
|
+
)
|
|
57
|
+
try:
|
|
58
|
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
59
|
+
raw = resp.read().decode("utf-8")
|
|
60
|
+
except urllib.error.HTTPError as exc:
|
|
61
|
+
detail = exc.read().decode("utf-8", errors="replace")
|
|
62
|
+
raise DashError(f"{method} {path} -> {exc.code}: {detail}") from exc
|
|
63
|
+
except urllib.error.URLError as exc:
|
|
64
|
+
raise DashError(f"{method} {path}: {exc.reason}") from exc
|
|
65
|
+
if not raw:
|
|
66
|
+
return {}
|
|
67
|
+
parsed = json.loads(raw)
|
|
68
|
+
if not isinstance(parsed, dict):
|
|
69
|
+
raise DashError(f"{method} {path}: expected object")
|
|
70
|
+
return parsed
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def push_dir(client: Client, dest: Path) -> str:
|
|
74
|
+
run_meta = _read_json(dest / "run.json")
|
|
75
|
+
report = _read_json(dest / "report.json")
|
|
76
|
+
created = client.create_run({**run_meta, "status": "done"})
|
|
77
|
+
run_id = str(created["id"])
|
|
78
|
+
events_path = dest / "events.jsonl"
|
|
79
|
+
if events_path.is_file():
|
|
80
|
+
rows = [_parse_event(line) for line in events_path.read_text().splitlines() if line.strip()]
|
|
81
|
+
if rows:
|
|
82
|
+
client.post_events(run_id, rows)
|
|
83
|
+
status = "fail" if report.get("failed") else "pass"
|
|
84
|
+
if report:
|
|
85
|
+
client.finish(run_id, report, status)
|
|
86
|
+
for path in dest.rglob("*"):
|
|
87
|
+
if not path.is_file():
|
|
88
|
+
continue
|
|
89
|
+
rel = path.relative_to(dest).as_posix()
|
|
90
|
+
if rel in {"events.jsonl"}:
|
|
91
|
+
continue
|
|
92
|
+
if path.stat().st_size > 256_000:
|
|
93
|
+
continue
|
|
94
|
+
try:
|
|
95
|
+
text = path.read_text()
|
|
96
|
+
except UnicodeDecodeError:
|
|
97
|
+
continue
|
|
98
|
+
client.upload_file(run_id, rel, text)
|
|
99
|
+
return run_id
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _read_json(path: Path) -> dict[str, Any]:
|
|
103
|
+
if not path.is_file():
|
|
104
|
+
return {}
|
|
105
|
+
raw = json.loads(path.read_text())
|
|
106
|
+
return raw if isinstance(raw, dict) else {}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _parse_event(line: str) -> dict[str, Any]:
|
|
110
|
+
row = json.loads(line)
|
|
111
|
+
return row if isinstance(row, dict) else {"raw": line}
|
argos/duration.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
_TOKEN = re.compile(r"(\d+(?:\.\d+)?)([hms])", re.I)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def parse_duration(raw: str) -> float:
|
|
7
|
+
text = raw.strip().lower()
|
|
8
|
+
if not text:
|
|
9
|
+
raise ValueError("empty duration")
|
|
10
|
+
if text.isdigit():
|
|
11
|
+
return float(text)
|
|
12
|
+
if _TOKEN.fullmatch(text) or _TOKEN.match(text):
|
|
13
|
+
total = 0.0
|
|
14
|
+
pos = 0
|
|
15
|
+
for match in _TOKEN.finditer(text):
|
|
16
|
+
if match.start() != pos:
|
|
17
|
+
raise ValueError(f"invalid duration {raw!r}")
|
|
18
|
+
value = float(match.group(1))
|
|
19
|
+
unit = match.group(2)
|
|
20
|
+
if unit == "h":
|
|
21
|
+
total += value * 3600
|
|
22
|
+
elif unit == "m":
|
|
23
|
+
total += value * 60
|
|
24
|
+
else:
|
|
25
|
+
total += value
|
|
26
|
+
pos = match.end()
|
|
27
|
+
if pos != len(text):
|
|
28
|
+
raise ValueError(f"invalid duration {raw!r}")
|
|
29
|
+
if total <= 0:
|
|
30
|
+
raise ValueError(f"duration must be > 0: {raw!r}")
|
|
31
|
+
return total
|
|
32
|
+
raise ValueError(f"invalid duration {raw!r} (use 8h, 90m, 45s, 1h30m)")
|