trap-cli 0.0.5__tar.gz → 0.0.7__tar.gz
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.
- {trap_cli-0.0.5 → trap_cli-0.0.7}/PKG-INFO +1 -1
- {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/_version.py +2 -2
- {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/auth/client.py +10 -1
- {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/cli/__init__.py +43 -22
- {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/display/submit.py +7 -4
- {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/git_ops/url.py +15 -0
- {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/loader/trap_yaml.py +1 -1
- {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/loader/traptask_yaml.py +22 -10
- {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/report/__init__.py +1 -2
- {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/runner/task.py +0 -14
- trap_cli-0.0.7/src/trap/workspace/__init__.py +23 -0
- trap_cli-0.0.7/src/trap/workspace/identity.py +58 -0
- trap_cli-0.0.7/src/trap/workspace/store.py +113 -0
- trap_cli-0.0.5/src/trap/report/handle.py +0 -54
- {trap_cli-0.0.5 → trap_cli-0.0.7}/.gitignore +0 -0
- {trap_cli-0.0.5 → trap_cli-0.0.7}/LICENSE +0 -0
- {trap_cli-0.0.5 → trap_cli-0.0.7}/README.md +0 -0
- {trap_cli-0.0.5 → trap_cli-0.0.7}/examples/echo/task/README.md +0 -0
- {trap_cli-0.0.5 → trap_cli-0.0.7}/pyproject.toml +0 -0
- {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/__init__.py +0 -0
- {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/auth/__init__.py +0 -0
- {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/auth/login.py +0 -0
- {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/auth/oauth.py +0 -0
- {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/auth/store.py +0 -0
- {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/cli/_auth.py +0 -0
- {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/cli/_console.py +0 -0
- {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/cost/__init__.py +0 -0
- {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/cost/calculator.py +0 -0
- {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/cost/providers.py +0 -0
- {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/cost/proxy.py +0 -0
- {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/display/__init__.py +0 -0
- {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/display/progress.py +0 -0
- {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/environment/__init__.py +0 -0
- {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/environment/detector.py +0 -0
- {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/git_ops/__init__.py +0 -0
- {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/git_ops/base.py +0 -0
- {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/git_ops/local.py +0 -0
- {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/git_ops/remote.py +0 -0
- {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/git_ops/rev.py +0 -0
- {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/loader/__init__.py +0 -0
- {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/loader/errors.py +0 -0
- {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/models/__init__.py +0 -0
- {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/models/cost.py +0 -0
- {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/models/environment.py +0 -0
- {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/models/provenance.py +0 -0
- {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/models/report.py +0 -0
- {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/models/results.py +0 -0
- {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/models/trap_yaml.py +0 -0
- {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/models/traptask_yaml.py +0 -0
- {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/report/base.py +0 -0
- {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/report/json.py +0 -0
- {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/report/rich.py +0 -0
- {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/runner/__init__.py +0 -0
- {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/runner/capture.py +0 -0
- {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/runner/grader.py +0 -0
- {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/runner/judge.py +0 -0
- {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/runner/layout.py +0 -0
- {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/runner/proc.py +0 -0
- {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/runner/solution.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: trap-cli
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.7
|
|
4
4
|
Summary: tp — non-invasive CLI testing framework for AI workflows. Submits results to trapstreet.run.
|
|
5
5
|
Project-URL: Homepage, https://trapstreet.run
|
|
6
6
|
Project-URL: Repository, https://github.com/trapstreet/trap
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '0.0.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 0,
|
|
21
|
+
__version__ = version = '0.0.7'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 0, 7)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -52,6 +52,15 @@ class ApiClient:
|
|
|
52
52
|
resp.raise_for_status()
|
|
53
53
|
return resp.json()
|
|
54
54
|
except httpx.HTTPStatusError as e:
|
|
55
|
-
raise ApiError(f"http {e.response.status_code}: {e.response
|
|
55
|
+
raise ApiError(f"http {e.response.status_code}: {_error_detail(e.response)}") from None
|
|
56
56
|
except httpx.RequestError as e:
|
|
57
57
|
raise ApiError(f"connection error: {e}") from None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _error_detail(resp: httpx.Response) -> str:
|
|
61
|
+
"""The server's JSON {error} message when present, else the raw body text."""
|
|
62
|
+
try:
|
|
63
|
+
error = resp.json().get("error")
|
|
64
|
+
except (ValueError, AttributeError):
|
|
65
|
+
return resp.text
|
|
66
|
+
return str(error) if error else resp.text
|
|
@@ -17,9 +17,10 @@ from trap.display import CaseProgress, render_submit_result
|
|
|
17
17
|
from trap.environment import EnvironmentDetector
|
|
18
18
|
from trap.git_ops import GitOpsError, LocalRepo, ParsedGitUrl
|
|
19
19
|
from trap.loader import ConfigError, TrapLoader, TraptaskLoader
|
|
20
|
-
from trap.models import Provenance
|
|
21
|
-
from trap.report import OutputFormat,
|
|
20
|
+
from trap.models import Provenance, ReportData
|
|
21
|
+
from trap.report import OutputFormat, renderer_factory
|
|
22
22
|
from trap.runner import TaskRunner
|
|
23
|
+
from trap.workspace import SolutionIdentity, Workspace
|
|
23
24
|
|
|
24
25
|
app = typer.Typer(help="AI prompt / agent / workflow / testing framework.")
|
|
25
26
|
app.add_typer(auth_app, name="auth")
|
|
@@ -46,6 +47,22 @@ def _main(
|
|
|
46
47
|
"""AI prompt / agent / workflow / testing framework."""
|
|
47
48
|
|
|
48
49
|
|
|
50
|
+
def _no_report(workspace: Workspace, error: FileNotFoundError) -> typer.Exit:
|
|
51
|
+
"""Exit for a report miss, with advice matching the likely cause: an empty
|
|
52
|
+
workspace means the user never ran; existing runs under *other* solution keys
|
|
53
|
+
mean a cwd / --solution mismatch, where re-running would just miss again."""
|
|
54
|
+
msg = str(error)
|
|
55
|
+
if keys := workspace.solution_keys():
|
|
56
|
+
msg += (
|
|
57
|
+
"\nsolutions with runs in this workspace: "
|
|
58
|
+
+ ", ".join(keys)
|
|
59
|
+
+ "\ncheck that --solution (and the cwd) match the ones used for [bold]tp run[/bold]"
|
|
60
|
+
)
|
|
61
|
+
else:
|
|
62
|
+
msg += ". Run [bold]tp run[/bold] first."
|
|
63
|
+
return _die(msg)
|
|
64
|
+
|
|
65
|
+
|
|
49
66
|
def _confirm_remote(url: str, *, trust: bool) -> None:
|
|
50
67
|
"""Gate trap's auto-download-and-run of a remote (git+ URL) source — the one place
|
|
51
68
|
trap fetches and executes code the user may not have seen. Returns to proceed;
|
|
@@ -145,7 +162,7 @@ def run(
|
|
|
145
162
|
help="Force-run the task's setup_cmd even when no remote pull brought new code.",
|
|
146
163
|
),
|
|
147
164
|
] = False,
|
|
148
|
-
workspace: Annotated[Path, typer.Option("--workspace", "-w")] = Path(
|
|
165
|
+
workspace: Annotated[Path, typer.Option("--workspace", "-w")] = Path(Workspace.DEFAULT_DIRNAME),
|
|
149
166
|
environment: Annotated[
|
|
150
167
|
bool,
|
|
151
168
|
typer.Option(
|
|
@@ -184,8 +201,8 @@ def run(
|
|
|
184
201
|
task_binding = trap_yaml_loader.resolve_task(task)
|
|
185
202
|
if ParsedGitUrl.looks_remote(task_binding.source):
|
|
186
203
|
_confirm_remote(task_binding.source, trust=trust)
|
|
187
|
-
traptask_yaml_loader = TraptaskLoader.
|
|
188
|
-
task_binding, trap_yaml_loader.trap_dir, setup=setup_task
|
|
204
|
+
traptask_yaml_loader = TraptaskLoader.from_task_binding(
|
|
205
|
+
task_binding, trap_yaml_loader.trap_dir, setup=setup_task, workspace_root=workspace.resolve()
|
|
189
206
|
)
|
|
190
207
|
except (GitOpsError, ConfigError, subprocess.CalledProcessError) as e:
|
|
191
208
|
raise _die(e) from None
|
|
@@ -204,14 +221,14 @@ def run(
|
|
|
204
221
|
|
|
205
222
|
started_at_local = datetime.now()
|
|
206
223
|
ts = started_at_local.isoformat(timespec="seconds")
|
|
207
|
-
|
|
224
|
+
ws = Workspace(workspace.resolve(), SolutionIdentity.from_spec(solution).dirname, task_binding.alias)
|
|
208
225
|
|
|
209
226
|
runner = TaskRunner(
|
|
210
227
|
trap_config=trap_yaml_loader.config,
|
|
211
228
|
trap_dir=trap_yaml_loader.trap_dir,
|
|
212
229
|
traptask_config=traptask_yaml_loader.traptask,
|
|
213
230
|
traptask_dir=traptask_yaml_loader.traptask_dir,
|
|
214
|
-
run_dir=
|
|
231
|
+
run_dir=ws.run_dir(ts),
|
|
215
232
|
cost_enabled=cost,
|
|
216
233
|
)
|
|
217
234
|
prog_console = console if output == OutputFormat.rich else None
|
|
@@ -233,8 +250,8 @@ def run(
|
|
|
233
250
|
except Exception:
|
|
234
251
|
environment_info = None
|
|
235
252
|
|
|
236
|
-
report_data =
|
|
237
|
-
|
|
253
|
+
report_data = ReportData.from_run(
|
|
254
|
+
cases_results=case_results,
|
|
238
255
|
trap_config=trap_yaml_loader.config,
|
|
239
256
|
started_at_utc=started_at_local.astimezone(UTC),
|
|
240
257
|
finished_at_utc=finished_at_utc,
|
|
@@ -242,7 +259,11 @@ def run(
|
|
|
242
259
|
provenance=provenance,
|
|
243
260
|
environment=environment_info,
|
|
244
261
|
)
|
|
262
|
+
ws.save_as_report(ts, report_data)
|
|
245
263
|
renderer_factory(output).render(report_data)
|
|
264
|
+
if output == OutputFormat.rich:
|
|
265
|
+
# Where the artifacts landed — the run id doubles as the `--run` handle.
|
|
266
|
+
console.print(f"[dim]run {ts} → {ws.report_json_path(ts)}[/dim]")
|
|
246
267
|
# trap reports facts, not a verdict: a completed run exits 0 regardless of per-case
|
|
247
268
|
# exit codes or scores (read the grader / report.json to gate CI). trap-level
|
|
248
269
|
# failures (bad config, git errors) still exit non-zero via _die.
|
|
@@ -257,18 +278,18 @@ def report(
|
|
|
257
278
|
typer.Option("--solution", help="Local solution path holding trap.yaml (default: cwd)."),
|
|
258
279
|
] = None,
|
|
259
280
|
output: Annotated[OutputFormat, typer.Option("--output", "-o")] = OutputFormat.rich,
|
|
260
|
-
workspace: Annotated[Path, typer.Option("--workspace", "-w")] = Path(
|
|
281
|
+
workspace: Annotated[Path, typer.Option("--workspace", "-w")] = Path(Workspace.DEFAULT_DIRNAME),
|
|
261
282
|
) -> None:
|
|
262
283
|
"""Display a report for a task (defaults to latest run)."""
|
|
263
284
|
try:
|
|
264
285
|
task_alias = TrapLoader.from_solution(solution).resolve_task(task).alias
|
|
265
286
|
except (GitOpsError, ConfigError) as e:
|
|
266
287
|
raise _die(e) from None
|
|
267
|
-
|
|
288
|
+
ws = Workspace(workspace.resolve(), SolutionIdentity.from_spec(solution).dirname, task_alias)
|
|
268
289
|
try:
|
|
269
|
-
report_data =
|
|
270
|
-
except FileNotFoundError:
|
|
271
|
-
raise
|
|
290
|
+
report_data = ws.load(run)
|
|
291
|
+
except FileNotFoundError as e:
|
|
292
|
+
raise _no_report(ws, e) from None
|
|
272
293
|
renderer_factory(output).render(report_data)
|
|
273
294
|
|
|
274
295
|
|
|
@@ -285,7 +306,7 @@ def submit(
|
|
|
285
306
|
str | None,
|
|
286
307
|
typer.Option("--solution", help="Local solution path holding trap.yaml (default: cwd)."),
|
|
287
308
|
] = None,
|
|
288
|
-
workspace: Annotated[Path, typer.Option("--workspace", "-w")] = Path(
|
|
309
|
+
workspace: Annotated[Path, typer.Option("--workspace", "-w")] = Path(Workspace.DEFAULT_DIRNAME),
|
|
289
310
|
run: Annotated[str, typer.Option("--run", "-r", help="Which run to upload.")] = "latest",
|
|
290
311
|
allow_unanchored: Annotated[
|
|
291
312
|
bool,
|
|
@@ -298,8 +319,8 @@ def submit(
|
|
|
298
319
|
) -> None:
|
|
299
320
|
"""Upload a report.json to trapstreet.
|
|
300
321
|
|
|
301
|
-
Reads from the .trap/<task>/<run>/report.json workspace
|
|
302
|
-
populated.
|
|
322
|
+
Reads from the .trap/runs/<solution>/<task>/<run>/report.json workspace
|
|
323
|
+
that `tp run` populated.
|
|
303
324
|
"""
|
|
304
325
|
stored = AuthStore().load()
|
|
305
326
|
# priority: TRAPSTREET_URL env > stored > default
|
|
@@ -315,17 +336,17 @@ def submit(
|
|
|
315
336
|
task_alias = TrapLoader.from_solution(solution).resolve_task(task).alias
|
|
316
337
|
except (GitOpsError, ConfigError) as e:
|
|
317
338
|
raise _die(e) from None
|
|
318
|
-
|
|
339
|
+
ws = Workspace(workspace.resolve(), SolutionIdentity.from_spec(solution).dirname, task_alias)
|
|
319
340
|
try:
|
|
320
|
-
report_data =
|
|
321
|
-
except FileNotFoundError:
|
|
322
|
-
raise
|
|
341
|
+
report_data = ws.load(run)
|
|
342
|
+
except FileNotFoundError as e:
|
|
343
|
+
raise _no_report(ws, e) from None
|
|
323
344
|
# Repeat the unanchored-provenance gate at upload time — the report records
|
|
324
345
|
# what `tp run` saw, so the checkouts aren't re-probed here.
|
|
325
346
|
_confirm_unanchored(
|
|
326
347
|
report_data.provenance, allow=allow_unanchored or _env_truthy("TRAP_ALLOW_UNANCHORED")
|
|
327
348
|
)
|
|
328
|
-
report_path =
|
|
349
|
+
report_path = ws.report_json_path(run)
|
|
329
350
|
|
|
330
351
|
client = ApiClient(resolved_server, resolved_key)
|
|
331
352
|
try:
|
|
@@ -7,14 +7,17 @@ console = Console()
|
|
|
7
7
|
|
|
8
8
|
|
|
9
9
|
def render_submit_result(resp_data: dict) -> None:
|
|
10
|
+
"""Render a successful POST /api/submit response: {run: {id}, view_url}.
|
|
11
|
+
|
|
12
|
+
Success is decided by the HTTP status alone (ApiClient.submit raises on
|
|
13
|
+
non-2xx), so this only confirms the upload and points at the run page —
|
|
14
|
+
scores and case detail live there, not in the response."""
|
|
10
15
|
run_obj = resp_data.get("run") or {}
|
|
11
|
-
|
|
16
|
+
console.print("[green]✓ submitted[/green]")
|
|
12
17
|
table = Table.grid(padding=(0, 2))
|
|
13
18
|
table.add_column(style="dim")
|
|
14
19
|
table.add_column()
|
|
15
|
-
table.add_row("
|
|
16
|
-
table.add_row("run", f"[bold]{run_obj.get('id', '?')}[/bold]")
|
|
17
|
-
table.add_row("score", f"[cyan]{run_obj.get('total_score')}[/cyan]")
|
|
20
|
+
table.add_row("run_id", f"[bold]{run_obj.get('id', '?')}[/bold]")
|
|
18
21
|
if view_url := resp_data.get("view_url"):
|
|
19
22
|
table.add_row("url", f"[link={view_url}]{view_url}[/link]")
|
|
20
23
|
console.print(table)
|
|
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import re
|
|
4
4
|
from dataclasses import dataclass
|
|
5
|
+
from pathlib import PurePosixPath
|
|
5
6
|
|
|
6
7
|
from trap.git_ops.base import GitOpsError
|
|
7
8
|
|
|
@@ -96,3 +97,17 @@ class ParsedGitUrl:
|
|
|
96
97
|
if m:
|
|
97
98
|
return f"https://{m.group(1)}/{m.group(2)}"
|
|
98
99
|
return re.sub(r"\.git$", "", self.repo) # https/http: drop trailing .git
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def dir_basename(self) -> str:
|
|
103
|
+
"""Basename of the directory the URL addresses: the subdirectory when one
|
|
104
|
+
is set, else the repo."""
|
|
105
|
+
return PurePosixPath(self.subdirectory).name if self.subdirectory else self.basename
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def normalised_dir_url(self) -> str:
|
|
109
|
+
"""Canonical, rev-independent address of the directory the URL points at:
|
|
110
|
+
``normalised_url`` plus ``#<subdirectory>`` when one is set. The rev is
|
|
111
|
+
excluded on purpose — it can float without changing *which* directory
|
|
112
|
+
this is."""
|
|
113
|
+
return self.normalised_url + (f"#{self.subdirectory}" if self.subdirectory else "")
|
|
@@ -68,7 +68,7 @@ class TrapLoader:
|
|
|
68
68
|
The solution's `setup_cmd` (in its trap.yaml, so it travels with the
|
|
69
69
|
solution) prepares the checkout. It auto-runs when a remote pull brought
|
|
70
70
|
new code, and otherwise only when `setup` is set (`tp run --setup-solution`).
|
|
71
|
-
Mirrors `TraptaskLoader.
|
|
71
|
+
Mirrors `TraptaskLoader.from_task_binding`.
|
|
72
72
|
"""
|
|
73
73
|
from trap.git_ops import GitOpsError, ParsedGitUrl, RemoteRepo
|
|
74
74
|
|
|
@@ -10,6 +10,7 @@ from pydantic import ValidationError
|
|
|
10
10
|
|
|
11
11
|
from trap.loader.errors import ConfigError
|
|
12
12
|
from trap.models import TaskBinding, TraptaskCase, TraptaskConfig
|
|
13
|
+
from trap.workspace import Workspace
|
|
13
14
|
|
|
14
15
|
|
|
15
16
|
class TraptaskLoader:
|
|
@@ -41,13 +42,21 @@ class TraptaskLoader:
|
|
|
41
42
|
return TraptaskConfig(cases=tuple(TraptaskCase(id=case_id) for case_id in case_ids))
|
|
42
43
|
|
|
43
44
|
@classmethod
|
|
44
|
-
def
|
|
45
|
+
def from_task_binding(
|
|
46
|
+
cls,
|
|
47
|
+
task_binding: TaskBinding,
|
|
48
|
+
trap_dir: Path,
|
|
49
|
+
setup: bool = False,
|
|
50
|
+
workspace_root: Path = Path(Workspace.DEFAULT_DIRNAME),
|
|
51
|
+
) -> TraptaskLoader:
|
|
45
52
|
"""Resolve traptask.yaml from a TaskBinding's source and the trap.yaml directory.
|
|
46
53
|
|
|
47
54
|
Mirrors `TrapLoader.from_solution`: `source` is a local path or a git+ URL.
|
|
48
|
-
A URL clones into `clone_to` (
|
|
49
|
-
|
|
50
|
-
|
|
55
|
+
A URL clones into `clone_to` (resolved against `trap_dir`, since it is the
|
|
56
|
+
solution author's config) or, when omitted, the workspace's hidden
|
|
57
|
+
`<workspace_root>/repos/<repo>` cache — the same root that holds run
|
|
58
|
+
artifacts; a local path uses it in place and rejects `clone_to`.
|
|
59
|
+
Raises GitOpsError on a bad spec (caller maps it to a CLI error).
|
|
51
60
|
|
|
52
61
|
The task's `setup_cmd` (declared in its traptask.yaml, so it travels with the
|
|
53
62
|
task version) prepares the checkout. It auto-runs when a remote pull brought
|
|
@@ -56,17 +65,20 @@ class TraptaskLoader:
|
|
|
56
65
|
"""
|
|
57
66
|
from trap.git_ops import GitOpsError, ParsedGitUrl, RemoteRepo
|
|
58
67
|
|
|
59
|
-
if ParsedGitUrl.looks_remote(
|
|
60
|
-
parsed = ParsedGitUrl.from_full_url(
|
|
61
|
-
|
|
62
|
-
|
|
68
|
+
if ParsedGitUrl.looks_remote(task_binding.source):
|
|
69
|
+
parsed = ParsedGitUrl.from_full_url(task_binding.source)
|
|
70
|
+
if task_binding.clone_to is not None:
|
|
71
|
+
dest = trap_dir / task_binding.clone_to
|
|
72
|
+
else:
|
|
73
|
+
dest = Workspace.clone_cache_dir(workspace_root, parsed.basename)
|
|
74
|
+
remote_repo = RemoteRepo(parsed, dest.resolve())
|
|
63
75
|
is_local_changed = remote_repo.ensure()
|
|
64
76
|
traptask_dir = remote_repo.local_dir
|
|
65
77
|
else:
|
|
66
|
-
if
|
|
78
|
+
if task_binding.clone_to is not None:
|
|
67
79
|
raise GitOpsError("clone_to only applies to a remote (git URL) source")
|
|
68
80
|
is_local_changed = False
|
|
69
|
-
traptask_dir = (trap_dir /
|
|
81
|
+
traptask_dir = (trap_dir / task_binding.source).resolve()
|
|
70
82
|
loader = cls(traptask_dir / "traptask.yaml")
|
|
71
83
|
if (is_local_changed or setup) and loader.traptask.setup_cmd:
|
|
72
84
|
# raises subprocess.CalledProcessError on non-zero exit
|
|
@@ -3,11 +3,10 @@ from __future__ import annotations
|
|
|
3
3
|
import enum
|
|
4
4
|
|
|
5
5
|
from trap.report.base import BaseRenderer
|
|
6
|
-
from trap.report.handle import ReportHandle
|
|
7
6
|
from trap.report.json import JsonRenderer
|
|
8
7
|
from trap.report.rich import RichRenderer
|
|
9
8
|
|
|
10
|
-
__all__ = ["BaseRenderer", "JsonRenderer", "OutputFormat", "
|
|
9
|
+
__all__ = ["BaseRenderer", "JsonRenderer", "OutputFormat", "RichRenderer", "renderer_factory"]
|
|
11
10
|
|
|
12
11
|
|
|
13
12
|
class OutputFormat(enum.StrEnum):
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from collections.abc import Callable, Iterable, Iterator
|
|
4
|
-
from datetime import datetime
|
|
5
4
|
from functools import cached_property
|
|
6
5
|
from pathlib import Path
|
|
7
6
|
from typing import Any
|
|
@@ -40,18 +39,6 @@ class TaskRunner:
|
|
|
40
39
|
"""The task's expected/ dir (traptask_dir / dirs.expected), resolved once on first use."""
|
|
41
40
|
return (self.traptask_dir / self.traptask_config.dirs.expected).resolve()
|
|
42
41
|
|
|
43
|
-
def _update_latest(self) -> None:
|
|
44
|
-
latest = self.run_dir.parent / "latest"
|
|
45
|
-
if latest.is_symlink():
|
|
46
|
-
latest.unlink()
|
|
47
|
-
elif latest.exists():
|
|
48
|
-
# Path exists but isn't a symlink — likely an interrupted prior run
|
|
49
|
-
# or a sync tool that materialized the symlink as a real directory.
|
|
50
|
-
# Move it aside (non-destructive) so subsequent runs self-heal.
|
|
51
|
-
suffix = datetime.now().isoformat(timespec="microseconds")
|
|
52
|
-
latest.rename(latest.with_name(f"latest.broken.{suffix}"))
|
|
53
|
-
latest.symlink_to(self.run_dir.name)
|
|
54
|
-
|
|
55
42
|
def _iter_cases(
|
|
56
43
|
self,
|
|
57
44
|
cases: Iterable[TraptaskCase],
|
|
@@ -98,5 +85,4 @@ class TaskRunner:
|
|
|
98
85
|
# still completes and the report still saves).
|
|
99
86
|
grader_metrics = GraderRunner(self, case_results).run()
|
|
100
87
|
|
|
101
|
-
self._update_latest()
|
|
102
88
|
return case_results, grader_metrics
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Workspace addressing: where a solution's runs live inside `.trap/`.
|
|
2
|
+
|
|
3
|
+
Layout: ``<workspace>/runs/<solution-key>/<task_alias>/<run>/``.
|
|
4
|
+
|
|
5
|
+
Two objects own this package's concerns: :class:`SolutionIdentity`
|
|
6
|
+
(``identity.py``) derives which solution a run belongs to and projects it to a
|
|
7
|
+
directory name (its ``dirname`` is the solution key), and :class:`Workspace`
|
|
8
|
+
(``store.py``) answers queries and does report IO against one `.trap/` store,
|
|
9
|
+
scoped to one (solution, task) pair fixed at construction. The key
|
|
10
|
+
carries the
|
|
11
|
+
run's *identity* inside the store, so ``latest`` is scoped per (solution, task)
|
|
12
|
+
by construction — a cwd mismatch between ``tp run`` and ``tp submit`` degrades
|
|
13
|
+
to a loud "not found" instead of silently reading another solution's report.
|
|
14
|
+
Runs live under the ``runs/`` namespace so run storage never collides with
|
|
15
|
+
sibling namespaces like ``repos/`` (the clone cache).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from trap.workspace.identity import SolutionIdentity
|
|
21
|
+
from trap.workspace.store import Workspace
|
|
22
|
+
|
|
23
|
+
__all__ = ["SolutionIdentity", "Workspace"]
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""SolutionIdentity — the identity a solution's runs are stored under."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from trap.git_ops import ParsedGitUrl
|
|
11
|
+
|
|
12
|
+
_UNSAFE = re.compile(r"[^A-Za-z0-9._-]+")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class SolutionIdentity:
|
|
17
|
+
"""The identity a solution's runs are stored under: ``<readable>-<hash8>``.
|
|
18
|
+
|
|
19
|
+
``readable`` is the solution's basename (never encodes path separators,
|
|
20
|
+
never leaks ``..``); ``ident`` is the full identity the hash disambiguates
|
|
21
|
+
same-named solutions by — the resolved absolute path, or the normalised
|
|
22
|
+
URL + subdirectory for a remote. Aliases of one solution (``./x``, ``x``,
|
|
23
|
+
an absolute path, a symlinked path) all derive the same key."""
|
|
24
|
+
|
|
25
|
+
readable: str
|
|
26
|
+
ident: str
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def from_spec(cls, solution: str | None) -> SolutionIdentity:
|
|
30
|
+
"""Derive the key from a ``--solution`` spec (local path or git+ URL)."""
|
|
31
|
+
if solution is not None and ParsedGitUrl.looks_remote(solution):
|
|
32
|
+
return cls._from_remote(ParsedGitUrl.from_full_url(solution))
|
|
33
|
+
return cls._from_local(solution)
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
def _from_remote(cls, parsed: ParsedGitUrl) -> SolutionIdentity:
|
|
37
|
+
# The URL is the stable identity — the clone dir can be moved (--clone-to)
|
|
38
|
+
# without changing which solution this is.
|
|
39
|
+
return cls(readable=parsed.dir_basename, ident=parsed.normalised_dir_url)
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def _from_local(cls, solution: str | None) -> SolutionIdentity:
|
|
43
|
+
resolved = (Path.cwd() / (solution or ".")).resolve()
|
|
44
|
+
return cls(readable=resolved.name, ident=str(resolved))
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def dirname(self) -> str:
|
|
48
|
+
"""The key as it appears on disk: the directory name under ``runs/``."""
|
|
49
|
+
return f"{self._safe_readable}-{self._digest}"
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def _safe_readable(self) -> str:
|
|
53
|
+
"""`readable` reduced to dirname-safe characters, with a non-empty fallback."""
|
|
54
|
+
return _UNSAFE.sub("-", self.readable).strip("-.") or "solution"
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def _digest(self) -> str:
|
|
58
|
+
return hashlib.sha256(self.ident.encode()).hexdigest()[:8]
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Workspace — one `.trap/` store and the queries it answers.
|
|
2
|
+
|
|
3
|
+
``latest`` is a *derived* value — the newest timestamp-named run directory that
|
|
4
|
+
holds a report, detected at every use. Nothing on disk records which run is
|
|
5
|
+
latest, so there is no pointer to go stale.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from trap.models import ReportData
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Workspace:
|
|
17
|
+
"""One `.trap/` store, scoped to one (solution, task) pair.
|
|
18
|
+
|
|
19
|
+
A CLI invocation resolves its solution key and task alias once, so both are
|
|
20
|
+
fixed at construction time — they address one run *series*. Which run inside
|
|
21
|
+
the series to touch is a per-call choice, so ``run`` stays a parameter."""
|
|
22
|
+
|
|
23
|
+
DEFAULT_DIRNAME = ".trap"
|
|
24
|
+
RUNS_DIR = "runs"
|
|
25
|
+
REPOS_DIR = "repos"
|
|
26
|
+
REPORT_FILENAME = "report.json"
|
|
27
|
+
|
|
28
|
+
def __init__(self, root: Path, solution_key: str, task_alias: str) -> None:
|
|
29
|
+
self.root = root
|
|
30
|
+
self.solution_key = solution_key
|
|
31
|
+
self.task_alias = task_alias
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def clone_cache_dir(cls, root: Path, repo_basename: str) -> Path:
|
|
35
|
+
"""Where a remote task clones when no ``clone_to`` is given: the hidden
|
|
36
|
+
``repos/`` cache inside the workspace at ``root`` — the same root that
|
|
37
|
+
holds ``runs/``, so the whole store lives in one place."""
|
|
38
|
+
return root / cls.REPOS_DIR / repo_basename
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def runs_root(self) -> Path:
|
|
42
|
+
return self.root / self.RUNS_DIR
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def solution_dir(self) -> Path:
|
|
46
|
+
"""This solution's namespace inside the store."""
|
|
47
|
+
return self.runs_root / self.solution_key
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def solution_task_alias_dir(self) -> Path:
|
|
51
|
+
"""Where this (solution, task) pair's runs live."""
|
|
52
|
+
return self.solution_dir / self.task_alias
|
|
53
|
+
|
|
54
|
+
def latest_run(self) -> str | None:
|
|
55
|
+
"""The newest completed run for this (solution, task), or None when there is none.
|
|
56
|
+
|
|
57
|
+
Derived from the directory names at every call — stray symlinks and
|
|
58
|
+
non-run entries are ignored."""
|
|
59
|
+
task_dir = self.solution_task_alias_dir
|
|
60
|
+
if not task_dir.is_dir():
|
|
61
|
+
return None
|
|
62
|
+
runs = (path.name for path in task_dir.iterdir() if self._is_completed_run(path))
|
|
63
|
+
return max(runs, default=None)
|
|
64
|
+
|
|
65
|
+
def resolved_run(self, run: str) -> str:
|
|
66
|
+
"""``"latest"`` resolves to the newest completed run; anything else names
|
|
67
|
+
a run directly (whether its report exists is ``assert_exists``'s concern).
|
|
68
|
+
Raises FileNotFoundError when there is no completed run to resolve
|
|
69
|
+
``"latest"`` to."""
|
|
70
|
+
if run != "latest":
|
|
71
|
+
return run
|
|
72
|
+
latest = self.latest_run()
|
|
73
|
+
if latest is None:
|
|
74
|
+
raise FileNotFoundError(f"no completed runs in {self.solution_task_alias_dir}")
|
|
75
|
+
return latest
|
|
76
|
+
|
|
77
|
+
def run_dir(self, run: str) -> Path:
|
|
78
|
+
"""One run's directory; ``run="latest"`` is resolved at every use."""
|
|
79
|
+
return self.solution_task_alias_dir / self.resolved_run(run)
|
|
80
|
+
|
|
81
|
+
def report_json_path(self, run: str) -> Path:
|
|
82
|
+
return self.run_dir(run) / self.REPORT_FILENAME
|
|
83
|
+
|
|
84
|
+
@classmethod
|
|
85
|
+
def _is_completed_run(cls, path: Path) -> bool:
|
|
86
|
+
"""A run counts when its directory is named by an ISO timestamp and holds
|
|
87
|
+
a report — half-written directories from a crashed run never win."""
|
|
88
|
+
if path.is_symlink() or not path.is_dir():
|
|
89
|
+
return False
|
|
90
|
+
try:
|
|
91
|
+
datetime.fromisoformat(path.name)
|
|
92
|
+
except ValueError:
|
|
93
|
+
return False
|
|
94
|
+
return (path / cls.REPORT_FILENAME).is_file()
|
|
95
|
+
|
|
96
|
+
def solution_keys(self) -> list[str]:
|
|
97
|
+
"""Solution keys with runs in this workspace — the self-help list for a miss."""
|
|
98
|
+
if not self.runs_root.is_dir():
|
|
99
|
+
return []
|
|
100
|
+
return sorted(path.name for path in self.runs_root.iterdir() if path.is_dir())
|
|
101
|
+
|
|
102
|
+
def save_as_report(self, run: str, data: ReportData) -> None:
|
|
103
|
+
"""Write ``run``'s report; ``run`` must name a concrete run, never ``"latest"``."""
|
|
104
|
+
self.run_dir(run).mkdir(parents=True, exist_ok=True)
|
|
105
|
+
self.report_json_path(run).write_text(data.model_dump_json(indent=2))
|
|
106
|
+
|
|
107
|
+
def assert_exists(self, run: str) -> None:
|
|
108
|
+
if not self.report_json_path(run).exists():
|
|
109
|
+
raise FileNotFoundError(f"no report found in {self.run_dir(run)}")
|
|
110
|
+
|
|
111
|
+
def load(self, run: str) -> ReportData:
|
|
112
|
+
self.assert_exists(run)
|
|
113
|
+
return ReportData.model_validate_json(self.report_json_path(run).read_text())
|
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from datetime import datetime
|
|
4
|
-
from pathlib import Path
|
|
5
|
-
from typing import Any
|
|
6
|
-
|
|
7
|
-
from trap.models import CaseResult, Environment, Provenance, ReportData, TrapConfig
|
|
8
|
-
|
|
9
|
-
_REPORT_FILENAME = "report.json"
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
class ReportHandle:
|
|
13
|
-
def __init__(self, workspace: Path, task_alias: str, run: str) -> None:
|
|
14
|
-
self._workspace = workspace
|
|
15
|
-
self._task_alias = task_alias
|
|
16
|
-
self._run = run
|
|
17
|
-
|
|
18
|
-
@property
|
|
19
|
-
def run_dir(self) -> Path:
|
|
20
|
-
return self._workspace / self._task_alias / self._run
|
|
21
|
-
|
|
22
|
-
@property
|
|
23
|
-
def report_json_path(self) -> Path:
|
|
24
|
-
return self.run_dir / _REPORT_FILENAME
|
|
25
|
-
|
|
26
|
-
def save(
|
|
27
|
-
self,
|
|
28
|
-
trap_config: TrapConfig,
|
|
29
|
-
case_results: tuple[CaseResult, ...],
|
|
30
|
-
started_at_utc: datetime,
|
|
31
|
-
finished_at_utc: datetime,
|
|
32
|
-
provenance: Provenance,
|
|
33
|
-
grader_metrics: Any = None,
|
|
34
|
-
environment: Environment | None = None,
|
|
35
|
-
) -> ReportData:
|
|
36
|
-
data = ReportData.from_run(
|
|
37
|
-
trap_config=trap_config,
|
|
38
|
-
provenance=provenance,
|
|
39
|
-
cases_results=case_results,
|
|
40
|
-
started_at_utc=started_at_utc,
|
|
41
|
-
finished_at_utc=finished_at_utc,
|
|
42
|
-
grader_metrics=grader_metrics,
|
|
43
|
-
environment=environment,
|
|
44
|
-
)
|
|
45
|
-
self.report_json_path.write_text(data.model_dump_json(indent=2))
|
|
46
|
-
return data
|
|
47
|
-
|
|
48
|
-
def assert_exists(self) -> None:
|
|
49
|
-
if not self.report_json_path.exists():
|
|
50
|
-
raise FileNotFoundError(f"no report found in {self.run_dir}")
|
|
51
|
-
|
|
52
|
-
def load(self) -> ReportData:
|
|
53
|
-
self.assert_exists()
|
|
54
|
-
return ReportData.model_validate_json(self.report_json_path.read_text())
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|