trap-cli 0.0.1__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.
- trap/__init__.py +6 -0
- trap/_version.py +24 -0
- trap/auth/__init__.py +15 -0
- trap/auth/client.py +56 -0
- trap/auth/login.py +39 -0
- trap/auth/oauth.py +82 -0
- trap/auth/store.py +39 -0
- trap/cli/__init__.py +274 -0
- trap/cli/_auth.py +103 -0
- trap/cost/__init__.py +3 -0
- trap/cost/calculator.py +41 -0
- trap/cost/providers.py +122 -0
- trap/cost/proxy.py +186 -0
- trap/display/__init__.py +4 -0
- trap/display/progress.py +61 -0
- trap/display/submit.py +20 -0
- trap/environment/__init__.py +3 -0
- trap/environment/detector.py +81 -0
- trap/git_ops/__init__.py +13 -0
- trap/git_ops/base.py +10 -0
- trap/git_ops/local.py +60 -0
- trap/git_ops/remote.py +63 -0
- trap/git_ops/rev.py +119 -0
- trap/git_ops/url.py +98 -0
- trap/loader/__init__.py +5 -0
- trap/loader/errors.py +7 -0
- trap/loader/trap_yaml.py +99 -0
- trap/loader/traptask_yaml.py +85 -0
- trap/models/__init__.py +36 -0
- trap/models/cost.py +36 -0
- trap/models/environment.py +24 -0
- trap/models/provenance.py +20 -0
- trap/models/report.py +61 -0
- trap/models/results.py +17 -0
- trap/models/trap_yaml.py +64 -0
- trap/models/traptask_yaml.py +58 -0
- trap/report/__init__.py +19 -0
- trap/report/base.py +8 -0
- trap/report/handle.py +54 -0
- trap/report/json.py +11 -0
- trap/report/rich.py +116 -0
- trap/runner/__init__.py +5 -0
- trap/runner/capture.py +34 -0
- trap/runner/grader.py +36 -0
- trap/runner/judge.py +50 -0
- trap/runner/layout.py +36 -0
- trap/runner/proc.py +122 -0
- trap/runner/solution.py +80 -0
- trap/runner/task.py +102 -0
- trap_cli-0.0.1.dist-info/METADATA +88 -0
- trap_cli-0.0.1.dist-info/RECORD +54 -0
- trap_cli-0.0.1.dist-info/WHEEL +4 -0
- trap_cli-0.0.1.dist-info/entry_points.txt +2 -0
- trap_cli-0.0.1.dist-info/licenses/LICENSE +21 -0
trap/models/trap_yaml.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# Models for trap.yaml (solution author's config).
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field, field_validator
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Profile(BaseModel):
|
|
11
|
+
# Self-reported engine identity, surfaced in the report. Both fields are
|
|
12
|
+
# multi-valued (a run may use several models/frameworks); a scalar is
|
|
13
|
+
# accepted as a convenience and normalised to a single-element list.
|
|
14
|
+
model: tuple[str, ...] = ()
|
|
15
|
+
framework: tuple[str, ...] = ()
|
|
16
|
+
|
|
17
|
+
@field_validator("model", "framework", mode="before")
|
|
18
|
+
@classmethod
|
|
19
|
+
def _as_list(cls, v: Any) -> Any:
|
|
20
|
+
if v is None:
|
|
21
|
+
return ()
|
|
22
|
+
if isinstance(v, str):
|
|
23
|
+
return (v,)
|
|
24
|
+
return v
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TaskBinding(BaseModel):
|
|
28
|
+
# A task binding: which task this solution is run against. The map key is the
|
|
29
|
+
# binding's `alias` — the solution author's handle for this task, which also
|
|
30
|
+
# serves as the local run-dir name and the trapstreet task_id on submit. The
|
|
31
|
+
# value is just where the task lives. Solution-invariant settings sit at the
|
|
32
|
+
# top level of trap.yaml (see TrapConfig).
|
|
33
|
+
alias: str = ""
|
|
34
|
+
# required: local task dir XOR git+ URL (relative to trap.yaml), same polymorphic
|
|
35
|
+
# form as --solution. A git+ URL is cloned into clone_to.
|
|
36
|
+
source: str
|
|
37
|
+
# clone target for a git+ source (relative to trap.yaml, or absolute);
|
|
38
|
+
# omitted → hidden cache .trap/repos/<repo>. Only valid when source is a URL.
|
|
39
|
+
clone_to: Path | None = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class TrapConfig(BaseModel):
|
|
43
|
+
# trap.yaml is one solution's file: its invariant settings sit at the top
|
|
44
|
+
# level (flat, 1:1 with the YAML), and `tasks` is the collection of task
|
|
45
|
+
# bindings it is run against.
|
|
46
|
+
# Field order mirrors the canonical trap.yaml layout.
|
|
47
|
+
# optional leaderboard identity; None → server auto-assigns a serial name
|
|
48
|
+
name: str | None = None
|
|
49
|
+
# self-reported engine identity (model/framework); plumbed to report.json `profile`
|
|
50
|
+
profile: Profile = Field(default_factory=Profile)
|
|
51
|
+
# Prepares the solution checkout once after clone (e.g. `uv sync`, `npm install`).
|
|
52
|
+
# Solution-author owned. Auto-runs on a remote clone/update; else `tp run --setup-solution`.
|
|
53
|
+
setup_cmd: str | None = None
|
|
54
|
+
stdin: str | None = None # optional: filename in inputs/{case_id}/ piped to the solution's stdin
|
|
55
|
+
cmd: str # run via shlex.split, cwd = the trap.yaml directory
|
|
56
|
+
# env var carrying the run manifest (inputs_dir / outputs_dir); override if needed
|
|
57
|
+
manifest_envvar: str = "TRAP_MANIFEST"
|
|
58
|
+
# per-case wall-clock ceiling (seconds). A safety net against hangs/runaways, not a
|
|
59
|
+
# fair budget — duration is recorded faithfully, so set it generously; a timed-out
|
|
60
|
+
# case = "did not complete". Solution-author owned.
|
|
61
|
+
timeout: int = 600
|
|
62
|
+
# free-form escape hatch for author notes; tolerated but never written to the report
|
|
63
|
+
extra: dict[str, Any] = {}
|
|
64
|
+
tasks: dict[str, TaskBinding]
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Models for traptask.yaml (task author's config).
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class SubprocessConfig(BaseModel):
|
|
8
|
+
# How to invoke the judge/grader subprocess: the command (run via shlex.split,
|
|
9
|
+
# cwd = traptask.yaml's directory) plus the env var carrying its manifest.
|
|
10
|
+
cmd: str
|
|
11
|
+
manifest_envvar: str = "TRAPTASK_MANIFEST"
|
|
12
|
+
# Per-invocation wall-clock ceiling (seconds): a safety net against a hung or
|
|
13
|
+
# runaway actor, not a time budget. On timeout the actor is killed and recorded
|
|
14
|
+
# as an error on the run (exit 124) — it never crashes the run. Task-author owned,
|
|
15
|
+
# so it is identical for every solution that runs this task version. The subclasses
|
|
16
|
+
# set the per-actor default.
|
|
17
|
+
timeout: int
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class JudgeConfig(SubprocessConfig):
|
|
21
|
+
# Runs once per case and may itself call an LLM (LLM-as-judge) → generous default.
|
|
22
|
+
timeout: int = 300
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class GraderConfig(SubprocessConfig):
|
|
26
|
+
# Only aggregates the per-case results → fast, so a tighter default.
|
|
27
|
+
timeout: int = 120
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class DirsConfig(BaseModel):
|
|
31
|
+
# paths relative to traptask.yaml; outputs dir is a runtime tmpdir, not declared here
|
|
32
|
+
inputs: str = "inputs/"
|
|
33
|
+
expected: str = "expected/"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class TraptaskCase(BaseModel):
|
|
37
|
+
id: str
|
|
38
|
+
description: str = "" # free-form author note; trap neither consumes nor displays it
|
|
39
|
+
tags: tuple[str, ...] = ()
|
|
40
|
+
skip: bool = False
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class TraptaskConfig(BaseModel):
|
|
44
|
+
# Field order mirrors the canonical traptask.yaml layout.
|
|
45
|
+
# Optional human-readable title for the task; task-author owned. Read from the task
|
|
46
|
+
# repo (via the report's provenance.task), so it stays consistent across every
|
|
47
|
+
# solution that runs this task version.
|
|
48
|
+
name: str | None = None
|
|
49
|
+
# Prepares the checkout (e.g. `uv sync`); task-author owned so every solution gets
|
|
50
|
+
# an identical env. Auto-runs on a remote clone/update; else `tp run --setup-task`.
|
|
51
|
+
setup_cmd: str | None = None
|
|
52
|
+
dirs: DirsConfig = DirsConfig()
|
|
53
|
+
# Advisory contract: filenames (and/or `stdout`/`stderr`) the solution writes.
|
|
54
|
+
# Never enforced — the judge is the sole arbiter. Omit for dynamic outputs.
|
|
55
|
+
declared_outputs: tuple[str, ...] = ()
|
|
56
|
+
cases: tuple[TraptaskCase, ...]
|
|
57
|
+
judge: JudgeConfig | None = None # None → skip per-case scoring
|
|
58
|
+
grader: GraderConfig | None = None # None → skip overall aggregation
|
trap/report/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import enum
|
|
4
|
+
|
|
5
|
+
from trap.report.base import BaseRenderer
|
|
6
|
+
from trap.report.handle import ReportHandle
|
|
7
|
+
from trap.report.json import JsonRenderer
|
|
8
|
+
from trap.report.rich import RichRenderer
|
|
9
|
+
|
|
10
|
+
__all__ = ["BaseRenderer", "JsonRenderer", "OutputFormat", "ReportHandle", "RichRenderer", "renderer_factory"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class OutputFormat(enum.StrEnum):
|
|
14
|
+
rich = "rich"
|
|
15
|
+
json = "json"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def renderer_factory(fmt: OutputFormat) -> BaseRenderer:
|
|
19
|
+
return {OutputFormat.rich: RichRenderer, OutputFormat.json: JsonRenderer}[fmt]()
|
trap/report/base.py
ADDED
trap/report/handle.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
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())
|
trap/report/json.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from trap.models import ReportData
|
|
6
|
+
from trap.report.base import BaseRenderer
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class JsonRenderer(BaseRenderer):
|
|
10
|
+
def render(self, data: ReportData) -> None:
|
|
11
|
+
sys.stdout.write(data.model_dump_json(indent=2) + "\n")
|
trap/report/rich.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
from rich import box
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.markup import escape
|
|
8
|
+
from rich.panel import Panel
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
from rich.text import Text
|
|
11
|
+
|
|
12
|
+
from trap.models import CaseResult, ReportData
|
|
13
|
+
from trap.report.base import BaseRenderer
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class RichRenderer(BaseRenderer):
|
|
17
|
+
def __init__(self, console: Console | None = None) -> None:
|
|
18
|
+
self.console = console or Console()
|
|
19
|
+
|
|
20
|
+
@staticmethod
|
|
21
|
+
def _get_metrics_keys(data: ReportData) -> set[str]:
|
|
22
|
+
return {key for result in data.cases_results if result.metrics for key in result.metrics}
|
|
23
|
+
|
|
24
|
+
@staticmethod
|
|
25
|
+
def _render_metric_cell(value: object) -> str:
|
|
26
|
+
if value is None:
|
|
27
|
+
return "[dim]—[/dim]"
|
|
28
|
+
if isinstance(value, bool):
|
|
29
|
+
return "[bold green]✓[/bold green]" if value else "[bold red]✗[/bold red]"
|
|
30
|
+
if isinstance(value, float) and 0.0 <= value <= 1.0:
|
|
31
|
+
pct = value * 100
|
|
32
|
+
if pct >= 100:
|
|
33
|
+
return f"[bold green]{pct:.0f}%[/bold green]"
|
|
34
|
+
if pct > 0:
|
|
35
|
+
return f"[bold yellow]{pct:.0f}%[/bold yellow]"
|
|
36
|
+
return f"[bold red]{pct:.0f}%[/bold red]"
|
|
37
|
+
# Bracket characters in user-supplied content (judge metric values,
|
|
38
|
+
# grader outputs, etc.) would otherwise be interpreted as Rich
|
|
39
|
+
# markup and crash with MarkupError on mismatched tags. e.g. a judge
|
|
40
|
+
# emitting a regex pattern like "[/\\-]" raises:
|
|
41
|
+
# MarkupError: closing tag '[/\\-]' doesn't match any open tag
|
|
42
|
+
# Escape so brackets render as literal text.
|
|
43
|
+
return escape(str(value))
|
|
44
|
+
|
|
45
|
+
@staticmethod
|
|
46
|
+
def _render_exit(result: CaseResult) -> str:
|
|
47
|
+
# The raw exit code — not a verdict. "did it run" (this) is a separate axis from
|
|
48
|
+
# "is it correct" (the judge's score columns); 124 is the timeout sentinel.
|
|
49
|
+
if result.exit_code == 0:
|
|
50
|
+
return "[dim]0[/dim]"
|
|
51
|
+
return f"[red]{result.exit_code}[/red]"
|
|
52
|
+
|
|
53
|
+
@staticmethod
|
|
54
|
+
def _render_cost(usd: float) -> str:
|
|
55
|
+
if usd <= 0:
|
|
56
|
+
return "[dim]—[/dim]"
|
|
57
|
+
if usd < 0.001:
|
|
58
|
+
return f"[dim]${usd:.5f}[/dim]"
|
|
59
|
+
return f"[dim]${usd:.4f}[/dim]"
|
|
60
|
+
|
|
61
|
+
def _build_table(self, data: ReportData) -> Table:
|
|
62
|
+
metrics_keys = self._get_metrics_keys(data)
|
|
63
|
+
has_cost = any(c.cost is not None for c in data.cases_results)
|
|
64
|
+
table = Table(box=box.ROUNDED, show_header=True, header_style="bold", expand=True)
|
|
65
|
+
table.add_column("case")
|
|
66
|
+
table.add_column("exit", justify="right")
|
|
67
|
+
table.add_column("time", justify="right", style="dim")
|
|
68
|
+
for key in metrics_keys:
|
|
69
|
+
table.add_column(f"# {escape(key)}", justify="right", header_style="bold cyan")
|
|
70
|
+
if has_cost:
|
|
71
|
+
table.add_column("prompt_tok", justify="right", style="dim")
|
|
72
|
+
table.add_column("compl_tok", justify="right", style="dim")
|
|
73
|
+
table.add_column("cost", justify="right")
|
|
74
|
+
for result in data.cases_results:
|
|
75
|
+
row = [escape(result.case_id), self._render_exit(result), f"{result.duration:.3f}s"]
|
|
76
|
+
for key in metrics_keys:
|
|
77
|
+
value = result.metrics.get(key) if result.metrics else None
|
|
78
|
+
row.append(self._render_metric_cell(value))
|
|
79
|
+
if has_cost:
|
|
80
|
+
if result.cost:
|
|
81
|
+
row.append(str(result.cost.prompt_tokens))
|
|
82
|
+
row.append(str(result.cost.completion_tokens))
|
|
83
|
+
row.append(self._render_cost(result.cost.cost_usd))
|
|
84
|
+
else:
|
|
85
|
+
row.extend(["[dim]—[/dim]", "[dim]—[/dim]", "[dim]—[/dim]"])
|
|
86
|
+
table.add_row(*row)
|
|
87
|
+
return table
|
|
88
|
+
|
|
89
|
+
def _build_summary(self, data: ReportData) -> Panel:
|
|
90
|
+
# Neutral execution tally — trap reports facts, not a verdict. "non-zero exit"
|
|
91
|
+
# just counts cases whose solution did not exit 0 (correctness is the judge's
|
|
92
|
+
# score, not this). The grader's own output, if any, is shown verbatim below.
|
|
93
|
+
results = data.cases_results
|
|
94
|
+
n_total = len(results)
|
|
95
|
+
n_errored = sum(1 for r in results if r.exit_code != 0)
|
|
96
|
+
parts = [f"{n_total} case{'s' if n_total != 1 else ''}"]
|
|
97
|
+
if n_errored:
|
|
98
|
+
parts.append(f"[red]{n_errored} non-zero exit[/red]")
|
|
99
|
+
|
|
100
|
+
rows: list[tuple[str, Text | str]] = [
|
|
101
|
+
("cases", Text.from_markup(" · ".join(parts) or "[dim]no cases[/dim]")),
|
|
102
|
+
]
|
|
103
|
+
if data.grader_metrics is not None:
|
|
104
|
+
rows.append(("grader", escape(json.dumps(data.grader_metrics, ensure_ascii=False))))
|
|
105
|
+
|
|
106
|
+
grid = Table.grid(padding=(0, 2))
|
|
107
|
+
grid.add_column(style="dim", justify="right")
|
|
108
|
+
grid.add_column()
|
|
109
|
+
for key, value in rows:
|
|
110
|
+
grid.add_row(key, value)
|
|
111
|
+
|
|
112
|
+
return Panel(grid, title="Summary", expand=True)
|
|
113
|
+
|
|
114
|
+
def render(self, data: ReportData) -> None:
|
|
115
|
+
self.console.print(self._build_summary(data))
|
|
116
|
+
self.console.print(self._build_table(data))
|
trap/runner/__init__.py
ADDED
trap/runner/capture.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class Capture:
|
|
11
|
+
"""The stdout/stderr/meta files trap writes for one actor's run.
|
|
12
|
+
|
|
13
|
+
Each actor (solution, judge, grader) gets its own directory; the capture
|
|
14
|
+
files are unprefixed within it (so `solution/stdout`, `judge/stdout`).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
stdout: Path
|
|
18
|
+
stderr: Path
|
|
19
|
+
meta: Path
|
|
20
|
+
|
|
21
|
+
@classmethod
|
|
22
|
+
def from_dir(cls, capture_dir: Path) -> Capture:
|
|
23
|
+
return cls(
|
|
24
|
+
stdout=capture_dir / "stdout",
|
|
25
|
+
stderr=capture_dir / "stderr",
|
|
26
|
+
meta=capture_dir / "meta.json",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
def write(self, stdout: str, stderr: str, meta: dict[str, Any]) -> None:
|
|
30
|
+
"""Create the actor directory and persist its run's stdout/stderr/meta."""
|
|
31
|
+
self.stdout.parent.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
self.stdout.write_text(stdout)
|
|
33
|
+
self.stderr.write_text(stderr)
|
|
34
|
+
self.meta.write_text(json.dumps(meta))
|
trap/runner/grader.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import TYPE_CHECKING, Any
|
|
5
|
+
|
|
6
|
+
from trap.models import CaseResult, GraderConfig
|
|
7
|
+
from trap.runner.capture import Capture
|
|
8
|
+
from trap.runner.proc import CapturedSubprocess
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from trap.runner.task import TaskRunner
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class GraderRunner:
|
|
15
|
+
def __init__(self, runner: TaskRunner, case_results: tuple[CaseResult, ...]) -> None:
|
|
16
|
+
assert runner.traptask_config.grader is not None
|
|
17
|
+
self.grader: GraderConfig = runner.traptask_config.grader
|
|
18
|
+
self.runner = runner
|
|
19
|
+
self.cases = case_results
|
|
20
|
+
# Run-level grader gets its own `grader/` directory next to report.json.
|
|
21
|
+
self.grader_dir = runner.run_dir / "grader"
|
|
22
|
+
self.capture = Capture.from_dir(self.grader_dir)
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def _manifest(self) -> str:
|
|
26
|
+
return json.dumps([c.model_dump() for c in self.cases])
|
|
27
|
+
|
|
28
|
+
def run(self) -> Any:
|
|
29
|
+
return CapturedSubprocess(
|
|
30
|
+
self.grader.cmd,
|
|
31
|
+
manifest_envvar=self.grader.manifest_envvar,
|
|
32
|
+
timeout=self.grader.timeout,
|
|
33
|
+
cwd=self.runner.traptask_dir,
|
|
34
|
+
manifest=self._manifest,
|
|
35
|
+
capture=self.capture,
|
|
36
|
+
).run_metrics_or_error(runner_name="grader")
|
trap/runner/judge.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import TYPE_CHECKING, Any
|
|
5
|
+
|
|
6
|
+
from trap.models import JudgeConfig
|
|
7
|
+
from trap.runner.proc import CapturedSubprocess
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from trap.runner.layout import CaseLayout
|
|
11
|
+
from trap.runner.task import TaskRunner
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class JudgeRunner:
|
|
15
|
+
def __init__(self, runner: TaskRunner, case_id: str, layout: CaseLayout) -> None:
|
|
16
|
+
self.runner = runner
|
|
17
|
+
self.case_id = case_id
|
|
18
|
+
self.case_inputs_dir = runner.task_inputs_dir / case_id # task-repo side
|
|
19
|
+
self.case_expected_dir = runner.task_expected_dir / case_id # task-repo side
|
|
20
|
+
self.layout = layout # workspace side
|
|
21
|
+
|
|
22
|
+
assert runner.traptask_config.judge is not None
|
|
23
|
+
self.judge: JudgeConfig = runner.traptask_config.judge
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def _manifest(self) -> str:
|
|
27
|
+
expected_dir = self.case_expected_dir
|
|
28
|
+
solution_capture = self.layout.solution_capture
|
|
29
|
+
return json.dumps(
|
|
30
|
+
{
|
|
31
|
+
"inputs_dir": str(self.case_inputs_dir.resolve()),
|
|
32
|
+
"expected_dir": str(expected_dir.resolve()) if expected_dir.exists() else None,
|
|
33
|
+
"outputs_dir": str(self.layout.outputs_dir.resolve()),
|
|
34
|
+
"run": {
|
|
35
|
+
"stdout": str(solution_capture.stdout.resolve()),
|
|
36
|
+
"stderr": str(solution_capture.stderr.resolve()),
|
|
37
|
+
"meta": str(solution_capture.meta.resolve()),
|
|
38
|
+
},
|
|
39
|
+
}
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
def run(self) -> Any:
|
|
43
|
+
return CapturedSubprocess(
|
|
44
|
+
self.judge.cmd,
|
|
45
|
+
manifest_envvar=self.judge.manifest_envvar,
|
|
46
|
+
timeout=self.judge.timeout,
|
|
47
|
+
cwd=self.runner.traptask_dir,
|
|
48
|
+
manifest=self._manifest,
|
|
49
|
+
capture=self.layout.judge_capture,
|
|
50
|
+
).run_metrics_or_error(runner_name="judge")
|
trap/runner/layout.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from trap.runner.capture import Capture
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class CaseLayout:
|
|
11
|
+
"""Where one case's run artefacts live in the .trap workspace.
|
|
12
|
+
|
|
13
|
+
Structure: ``{run_dir}/{case_id}/{solution,judge}/...``. This is the single
|
|
14
|
+
source of truth for the per-case directory layout, so the runners derive
|
|
15
|
+
their paths from here instead of each re-joining the same strings.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
case_dir: Path
|
|
19
|
+
|
|
20
|
+
@classmethod
|
|
21
|
+
def for_case(cls, run_dir: Path, case_id: str) -> CaseLayout:
|
|
22
|
+
return cls(run_dir / case_id)
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def outputs_dir(self) -> Path:
|
|
26
|
+
"""Solution-written files only — the manifest's ``outputs_dir``."""
|
|
27
|
+
return self.case_dir / "solution" / "outputs"
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def solution_capture(self) -> Capture:
|
|
31
|
+
"""The solution run's stdout/stderr/meta — the manifest's ``run``."""
|
|
32
|
+
return Capture.from_dir(self.case_dir / "solution")
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def judge_capture(self) -> Capture:
|
|
36
|
+
return Capture.from_dir(self.case_dir / "judge")
|
trap/runner/proc.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import shlex
|
|
6
|
+
import subprocess
|
|
7
|
+
import time
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import TYPE_CHECKING, Any
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from trap.runner.capture import Capture
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class ProcResult:
|
|
18
|
+
"""The normalised outcome of one run. A timeout is folded in here as
|
|
19
|
+
``exit_code == CapturedSubprocess.TIMEOUT_EXIT_CODE`` (124) with whatever partial
|
|
20
|
+
output we got, so callers never see a raw TimeoutExpired."""
|
|
21
|
+
|
|
22
|
+
stdout: str
|
|
23
|
+
stderr: str
|
|
24
|
+
exit_code: int
|
|
25
|
+
duration: float
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class CapturedSubprocess:
|
|
29
|
+
"""A subprocess trap runs and whose streams it captures to the workspace — the
|
|
30
|
+
solution, a judge, or a grader. On top of ``subprocess.run`` it owns the shared
|
|
31
|
+
lifecycle: assemble the env, run the command (folding a timeout into exit 124 +
|
|
32
|
+
partial output), time it, and persist the capture.
|
|
33
|
+
|
|
34
|
+
Two consume methods, neither of which raises (failure is always in-band) — they
|
|
35
|
+
differ because the solution and the judge/grader play opposite roles:
|
|
36
|
+
|
|
37
|
+
- ``run()`` (solution) returns the raw ProcResult. The solution is the *subject
|
|
38
|
+
under test*, so a non-zero exit or a timeout is a legitimate outcome to measure
|
|
39
|
+
(a case may even expect exit 1) — not an error. It is handed back verbatim as
|
|
40
|
+
data for the judge to evaluate.
|
|
41
|
+
- ``run_metrics_or_error()`` (judge/grader) returns the actor's parsed JSON
|
|
42
|
+
metrics. A judge/grader is the *measuring apparatus*: if it times out, exits
|
|
43
|
+
non-zero, or emits non-JSON it produced no verdict at all, so that is folded
|
|
44
|
+
into an ``{"error": ...}`` metric (isolated, so one broken judge or grader never
|
|
45
|
+
crashes the run or loses the report).
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
# conventional exit code for "a subprocess was killed for exceeding its timeout"
|
|
49
|
+
TIMEOUT_EXIT_CODE = 124
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
cmd: str,
|
|
54
|
+
*,
|
|
55
|
+
manifest_envvar: str,
|
|
56
|
+
timeout: int,
|
|
57
|
+
cwd: Path,
|
|
58
|
+
manifest: str,
|
|
59
|
+
capture: Capture,
|
|
60
|
+
stdin: str | None = None,
|
|
61
|
+
env_extra: dict[str, str] | None = None,
|
|
62
|
+
) -> None:
|
|
63
|
+
self.cmd = cmd
|
|
64
|
+
self.manifest_envvar = manifest_envvar
|
|
65
|
+
self.timeout = timeout
|
|
66
|
+
self.cwd = cwd
|
|
67
|
+
self.manifest = manifest
|
|
68
|
+
self.capture = capture
|
|
69
|
+
self.stdin = stdin
|
|
70
|
+
self.env_extra = env_extra or {}
|
|
71
|
+
|
|
72
|
+
def run(self) -> ProcResult:
|
|
73
|
+
"""Run and capture, returning the raw ProcResult. A timeout is folded in (exit
|
|
74
|
+
124) but never raised — for the solution a failed/timed-out run is a measured
|
|
75
|
+
outcome, so the caller reads ``exit_code`` rather than catching."""
|
|
76
|
+
env = {**os.environ, self.manifest_envvar: self.manifest, **self.env_extra}
|
|
77
|
+
t0 = time.monotonic()
|
|
78
|
+
try:
|
|
79
|
+
proc = subprocess.run(
|
|
80
|
+
shlex.split(self.cmd),
|
|
81
|
+
input=self.stdin,
|
|
82
|
+
capture_output=True,
|
|
83
|
+
text=True,
|
|
84
|
+
cwd=self.cwd,
|
|
85
|
+
timeout=self.timeout,
|
|
86
|
+
env=env,
|
|
87
|
+
)
|
|
88
|
+
stdout, stderr, exit_code = proc.stdout, proc.stderr, proc.returncode
|
|
89
|
+
except subprocess.TimeoutExpired as e:
|
|
90
|
+
stdout = self._as_text(e.stdout)
|
|
91
|
+
stderr = self._as_text(e.stderr) + f"\n[trap] timed out after {self.timeout}s"
|
|
92
|
+
exit_code = self.TIMEOUT_EXIT_CODE
|
|
93
|
+
|
|
94
|
+
duration = time.monotonic() - t0
|
|
95
|
+
self.capture.write(stdout, stderr, {"exit_code": exit_code, "duration": duration})
|
|
96
|
+
return ProcResult(stdout, stderr, exit_code, duration)
|
|
97
|
+
|
|
98
|
+
def run_metrics_or_error(self, *, runner_name: str) -> Any:
|
|
99
|
+
"""Run the actor and return its parsed JSON metrics. A broken actor — timeout,
|
|
100
|
+
non-zero exit, or non-JSON output — is folded into an ``{"error": ...}`` metric
|
|
101
|
+
rather than raised, so one bad judge/grader never crashes the run or loses the
|
|
102
|
+
report (full detail is in the actor's stderr capture). ``runner_name`` ("judge" /
|
|
103
|
+
"grader") just labels the error."""
|
|
104
|
+
result = self.run()
|
|
105
|
+
if result.exit_code == self.TIMEOUT_EXIT_CODE:
|
|
106
|
+
return {"error": f"{runner_name} timed out after {self.timeout}s"}
|
|
107
|
+
if result.exit_code != 0:
|
|
108
|
+
return {"error": f"{runner_name} exited with status {result.exit_code}"}
|
|
109
|
+
try:
|
|
110
|
+
return json.loads(result.stdout)
|
|
111
|
+
except json.JSONDecodeError as e:
|
|
112
|
+
return {"error": f"{runner_name} produced invalid JSON: {e}"}
|
|
113
|
+
|
|
114
|
+
@staticmethod
|
|
115
|
+
def _as_text(out: object) -> str:
|
|
116
|
+
"""Partial stdout/stderr off a TimeoutExpired may be a bytes-like buffer (or
|
|
117
|
+
absent) even with text=True; normalise whatever we got to a str."""
|
|
118
|
+
if isinstance(out, str):
|
|
119
|
+
return out
|
|
120
|
+
if isinstance(out, (bytes, bytearray, memoryview)):
|
|
121
|
+
return bytes(out).decode(errors="replace")
|
|
122
|
+
return ""
|