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 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)")