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.
Files changed (54) hide show
  1. trap/__init__.py +6 -0
  2. trap/_version.py +24 -0
  3. trap/auth/__init__.py +15 -0
  4. trap/auth/client.py +56 -0
  5. trap/auth/login.py +39 -0
  6. trap/auth/oauth.py +82 -0
  7. trap/auth/store.py +39 -0
  8. trap/cli/__init__.py +274 -0
  9. trap/cli/_auth.py +103 -0
  10. trap/cost/__init__.py +3 -0
  11. trap/cost/calculator.py +41 -0
  12. trap/cost/providers.py +122 -0
  13. trap/cost/proxy.py +186 -0
  14. trap/display/__init__.py +4 -0
  15. trap/display/progress.py +61 -0
  16. trap/display/submit.py +20 -0
  17. trap/environment/__init__.py +3 -0
  18. trap/environment/detector.py +81 -0
  19. trap/git_ops/__init__.py +13 -0
  20. trap/git_ops/base.py +10 -0
  21. trap/git_ops/local.py +60 -0
  22. trap/git_ops/remote.py +63 -0
  23. trap/git_ops/rev.py +119 -0
  24. trap/git_ops/url.py +98 -0
  25. trap/loader/__init__.py +5 -0
  26. trap/loader/errors.py +7 -0
  27. trap/loader/trap_yaml.py +99 -0
  28. trap/loader/traptask_yaml.py +85 -0
  29. trap/models/__init__.py +36 -0
  30. trap/models/cost.py +36 -0
  31. trap/models/environment.py +24 -0
  32. trap/models/provenance.py +20 -0
  33. trap/models/report.py +61 -0
  34. trap/models/results.py +17 -0
  35. trap/models/trap_yaml.py +64 -0
  36. trap/models/traptask_yaml.py +58 -0
  37. trap/report/__init__.py +19 -0
  38. trap/report/base.py +8 -0
  39. trap/report/handle.py +54 -0
  40. trap/report/json.py +11 -0
  41. trap/report/rich.py +116 -0
  42. trap/runner/__init__.py +5 -0
  43. trap/runner/capture.py +34 -0
  44. trap/runner/grader.py +36 -0
  45. trap/runner/judge.py +50 -0
  46. trap/runner/layout.py +36 -0
  47. trap/runner/proc.py +122 -0
  48. trap/runner/solution.py +80 -0
  49. trap/runner/task.py +102 -0
  50. trap_cli-0.0.1.dist-info/METADATA +88 -0
  51. trap_cli-0.0.1.dist-info/RECORD +54 -0
  52. trap_cli-0.0.1.dist-info/WHEEL +4 -0
  53. trap_cli-0.0.1.dist-info/entry_points.txt +2 -0
  54. trap_cli-0.0.1.dist-info/licenses/LICENSE +21 -0
@@ -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
@@ -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
@@ -0,0 +1,8 @@
1
+ from __future__ import annotations
2
+
3
+ from trap.models import ReportData
4
+
5
+
6
+ class BaseRenderer:
7
+ def render(self, data: ReportData) -> None:
8
+ raise NotImplementedError
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))
@@ -0,0 +1,5 @@
1
+ from __future__ import annotations
2
+
3
+ from trap.runner.task import TaskRunner
4
+
5
+ __all__ = ["TaskRunner"]
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 ""