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
trap/git_ops/rev.py ADDED
@@ -0,0 +1,119 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from abc import ABC, abstractmethod
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ import git
9
+
10
+ from trap.git_ops.base import GitOpsError, ProgressCallback
11
+
12
+ # Each rev kind owns its full lifecycle — fresh clone and reconcile-existing —
13
+ # so the behaviour for one kind reads top-to-bottom in one place rather than
14
+ # being split across the clone and sync paths.
15
+
16
+
17
+ class RevStrategy(ABC):
18
+ @classmethod
19
+ def for_rev(cls, rev: str | None) -> RevStrategy:
20
+ """Pick the strategy for a rev string (knowable without network)."""
21
+ if rev is None:
22
+ return DefaultBranch()
23
+ if re.fullmatch(r"[0-9a-f]{7,40}", rev):
24
+ return PinnedSha(rev)
25
+ return NamedRef(rev)
26
+
27
+ @abstractmethod
28
+ def clone(self, repo_url: str, dest: Path) -> None:
29
+ """Fresh clone of repo_url into dest, positioned at the desired rev."""
30
+
31
+ @abstractmethod
32
+ def reconcile(self, repo: git.Repo, root: Path, progress_func: ProgressCallback) -> bool:
33
+ """Verify/update an existing clone (remote already validated).
34
+
35
+ Returns True if local code changed (caller then auto-runs setup_cmd).
36
+ """
37
+
38
+
39
+ class DefaultBranch(RevStrategy):
40
+ """No rev pinned — clone default branch; never auto-update an existing clone."""
41
+
42
+ def clone(self, repo_url: str, dest: Path) -> None:
43
+ git.Repo.clone_from(repo_url, dest)
44
+
45
+ def reconcile(self, repo: git.Repo, root: Path, progress_func: ProgressCallback) -> bool:
46
+ return False
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class PinnedSha(RevStrategy):
51
+ """Immutable SHA — clone then checkout; verify HEAD offline, never fetch."""
52
+
53
+ sha: str
54
+
55
+ def clone(self, repo_url: str, dest: Path) -> None:
56
+ repo = git.Repo.clone_from(repo_url, dest)
57
+ repo.git.checkout(self.sha)
58
+
59
+ def reconcile(self, repo: git.Repo, root: Path, progress_func: ProgressCallback) -> bool:
60
+ head_sha = repo.head.commit.hexsha
61
+ if not head_sha.startswith(self.sha):
62
+ raise GitOpsError(
63
+ f"rev mismatch at {root}:\n declared: {self.sha}\n HEAD: {head_sha[: len(self.sha)]}"
64
+ )
65
+ return False
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class NamedRef(RevStrategy):
70
+ """Tag or branch — distinguished only after fetch: tags are immutable (error
71
+ on drift), branches fast-forward when behind."""
72
+
73
+ ref: str
74
+
75
+ def clone(self, repo_url: str, dest: Path) -> None:
76
+ git.Repo.clone_from(repo_url, dest, branch=self.ref, single_branch=True)
77
+
78
+ def reconcile(self, repo: git.Repo, root: Path, progress_func: ProgressCallback) -> bool:
79
+ head_sha = repo.head.commit.hexsha
80
+
81
+ if progress_func:
82
+ progress_func(f"fetching {root.name}...")
83
+ try:
84
+ repo.remotes.origin.fetch()
85
+ except git.GitCommandError as exc:
86
+ raise GitOpsError(f"git fetch failed:\n{exc.stderr.strip()}") from exc
87
+
88
+ declared_sha = self._resolve_sha(repo)
89
+ if declared_sha == head_sha:
90
+ return False # already up to date
91
+
92
+ is_branch = any(r.remote_head == self.ref for r in repo.remotes.origin.refs)
93
+ if not is_branch:
94
+ raise GitOpsError( # pragma: no cover - only a force-moved remote tag reaches here
95
+ f"rev mismatch at {root}:\n"
96
+ f" declared tag: {self.ref} ({declared_sha[:8]})\n"
97
+ f" HEAD: {head_sha[:8]}"
98
+ )
99
+
100
+ if progress_func:
101
+ progress_func(f"updating {root.name} to {self.ref} ({declared_sha[:8]})...")
102
+ try:
103
+ repo.git.pull("--ff-only", "origin", self.ref)
104
+ except git.GitCommandError as exc:
105
+ raise GitOpsError(
106
+ f"cannot fast-forward {root} to origin/{self.ref}:\n"
107
+ f"{exc.stderr.strip()}\n"
108
+ f"Local branch has diverged. Delete {root} and re-run."
109
+ ) from exc
110
+ return True
111
+
112
+ def _resolve_sha(self, repo: git.Repo) -> str:
113
+ """Resolve the ref to a full commit SHA (call after fetch)."""
114
+ for candidate in [f"origin/{self.ref}", self.ref]:
115
+ try:
116
+ return repo.commit(candidate).hexsha
117
+ except git.BadName: # pragma: no cover - defensive: a resolvable ref was just fetched
118
+ continue
119
+ raise GitOpsError(f"cannot resolve rev {self.ref!r}") # pragma: no cover
trap/git_ops/url.py ADDED
@@ -0,0 +1,98 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+
6
+ from trap.git_ops.base import GitOpsError
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class ParsedGitUrl:
11
+ """A git URL split into its parts, for cloning a task/solution repo.
12
+
13
+ A user writes one string (in trap.yaml or `--solution`); this pulls it apart
14
+ into the URL to clone, an optional pinned rev, and an optional sub-path.
15
+
16
+ git+https://github.com/org/repo@v1.0#subdirectory=tasks/a
17
+ └──────── repo ───────┘ └rev┘ └──── subdirectory ────┘
18
+ """
19
+
20
+ repo: str # the underlying git URL (git+ prefix stripped, no @rev, no #fragment)
21
+ rev: str | None # branch / tag / SHA; None = default branch
22
+ subdirectory: str | None # sub-path inside the repo; None = root
23
+
24
+ @staticmethod
25
+ def looks_remote(s: str) -> bool:
26
+ """True if `s` is a git remote URL (vs a local path).
27
+
28
+ A `scheme://…` URL (git+https, https, ssh, git, file) or scp shorthand
29
+ `user@host:path`. Local paths (./x, ../task, /abs, foo) have neither.
30
+ """
31
+ return "://" in s or bool(re.match(r"^[^/@]+@[^/:]+:", s))
32
+
33
+ @classmethod
34
+ def from_full_url(cls, url: str) -> ParsedGitUrl:
35
+ """Parse a git URL into (repo, rev, subdirectory).
36
+
37
+ Accepts any of these base forms — the `git+` prefix is optional:
38
+
39
+ git+https://github.com/org/repo
40
+ https://github.com/org/repo.git
41
+ ssh://git@github.com/org/repo
42
+ git@github.com:org/repo.git (scp shorthand)
43
+
44
+ (Any `scheme://` works, so `file:///path/to/repo` also clones a local
45
+ repo — handy for tests/pinning, distinct from an in-place local path.)
46
+
47
+ Two optional suffixes work on every form:
48
+
49
+ @<rev> pin a branch / tag / commit
50
+ #subdirectory=<p> sub-path inside the repo holding the trap config
51
+
52
+ Example:
53
+
54
+ git+https://github.com/org/repo@v1.0#subdirectory=tasks/a
55
+ → repo="https://github.com/org/repo", rev="v1.0", subdirectory="tasks/a"
56
+
57
+ Raises GitOpsError if `url` is not a remote URL (see `looks_remote`).
58
+ """
59
+ if not cls.looks_remote(url):
60
+ raise GitOpsError(f"not a git URL: {url!r}")
61
+ rest = url[4:] if url.startswith("git+") else url
62
+
63
+ subdirectory: str | None = None
64
+ if "#" in rest:
65
+ rest, fragment = rest.split("#", 1)
66
+ for part in fragment.split("&"):
67
+ if part.startswith("subdirectory="):
68
+ subdirectory = part[len("subdirectory=") :]
69
+
70
+ rev: str | None = None
71
+ # only look for @rev in the last path segment to avoid cutting user@host
72
+ if "@" in rest.split("/", 3)[-1]:
73
+ idx = rest.rfind("@")
74
+ rest, rev = rest[:idx], rest[idx + 1 :]
75
+
76
+ return cls(repo=rest, rev=rev, subdirectory=subdirectory)
77
+
78
+ @property
79
+ def basename(self) -> str:
80
+ """https://github.com/org/my-task.git → my-task"""
81
+ name = self.repo.rstrip("/").rsplit("/", 1)[-1]
82
+ return re.sub(r"\.git$", "", name)
83
+
84
+ @property
85
+ def normalised_url(self) -> str:
86
+ """`repo` as a canonical clickable https URL.
87
+
88
+ git@github.com:user/repo.git → https://github.com/user/repo
89
+ ssh://git@gitlab.com/u/r → https://gitlab.com/u/r
90
+ https://github.com/u/r.git → https://github.com/u/r
91
+ """
92
+ m = re.match(r"^git@([^:]+):(.+?)(?:\.git)?$", self.repo) # scp git@host:path
93
+ if m:
94
+ return f"https://{m.group(1)}/{m.group(2)}"
95
+ m = re.match(r"^ssh://git@([^/]+)/(.+?)(?:\.git)?$", self.repo) # ssh:// form
96
+ if m:
97
+ return f"https://{m.group(1)}/{m.group(2)}"
98
+ return re.sub(r"\.git$", "", self.repo) # https/http: drop trailing .git
@@ -0,0 +1,5 @@
1
+ from .errors import ConfigError
2
+ from .trap_yaml import TrapLoader
3
+ from .traptask_yaml import TraptaskLoader
4
+
5
+ __all__ = ["ConfigError", "TrapLoader", "TraptaskLoader"]
trap/loader/errors.py ADDED
@@ -0,0 +1,7 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class ConfigError(Exception):
5
+ """A trap.yaml / traptask.yaml that cannot be loaded — missing file, malformed
6
+ YAML, failed schema validation, an unknown task alias, or no tasks/cases. Carries a
7
+ user-facing message; the CLI maps it to a clean error (exit 2, no traceback)."""
@@ -0,0 +1,99 @@
1
+ # Loads trap.yaml (solution author's config) into TrapLoader.
2
+ from __future__ import annotations
3
+
4
+ import subprocess
5
+ from pathlib import Path
6
+ from typing import TYPE_CHECKING
7
+
8
+ import yaml
9
+ from pydantic import ValidationError
10
+
11
+ from trap.loader.errors import ConfigError
12
+ from trap.models import TaskBinding
13
+ from trap.models.trap_yaml import TrapConfig
14
+
15
+ if TYPE_CHECKING:
16
+ from trap.git_ops.base import ProgressCallback
17
+
18
+
19
+ class TrapLoader:
20
+ """Loads trap.yaml (solution author's config)."""
21
+
22
+ def __init__(self, trap_yaml_path: Path) -> None:
23
+ self.trap_dir: Path = trap_yaml_path.resolve().parent
24
+ try:
25
+ text = trap_yaml_path.read_text()
26
+ except FileNotFoundError:
27
+ raise ConfigError(f"no trap.yaml at {trap_yaml_path}") from None
28
+ try:
29
+ data = yaml.safe_load(text)
30
+ except yaml.YAMLError as e:
31
+ raise ConfigError(f"invalid YAML in {trap_yaml_path}: {e}") from e
32
+ try:
33
+ self.config: TrapConfig = TrapConfig.model_validate(data)
34
+ except ValidationError as e:
35
+ raise ConfigError(f"invalid trap.yaml ({trap_yaml_path}):\n{e}") from e
36
+ self.tasks: dict[str, TaskBinding] = {
37
+ alias: task.model_copy(update={"alias": alias}) for alias, task in self.config.tasks.items()
38
+ }
39
+
40
+ def resolve_task(self, alias: str | None) -> TaskBinding:
41
+ """Return the task for `alias` (the `tasks:` map key), or the first task if None."""
42
+ if alias is None:
43
+ if not self.tasks:
44
+ raise ConfigError(f"trap.yaml in {self.trap_dir} defines no tasks")
45
+ alias = next(iter(self.tasks))
46
+ if alias not in self.tasks:
47
+ available = ", ".join(self.tasks) or "(none)"
48
+ raise ConfigError(f"task {alias!r} not found in trap.yaml; available: {available}")
49
+ return self.tasks[alias]
50
+
51
+ @classmethod
52
+ def from_solution(
53
+ cls,
54
+ solution: str | None,
55
+ clone_to: Path | None = None,
56
+ *,
57
+ allow_remote: bool = False,
58
+ setup: bool = False,
59
+ progress_func: ProgressCallback = None,
60
+ ) -> TrapLoader:
61
+ """Resolve a --solution spec to a loaded TrapLoader (relative to cwd).
62
+
63
+ None → ./trap.yaml. Local path → <path>/trap.yaml. git+ URL
64
+ (allow_remote only) → clone into ./<repo> (or clone_to) and load its
65
+ trap.yaml. Raises GitOpsError on a git failure or a bad spec/flag
66
+ combo (caller maps it to a CLI error).
67
+
68
+ The solution's `setup_cmd` (in its trap.yaml, so it travels with the
69
+ solution) prepares the checkout. It auto-runs when a remote pull brought
70
+ new code, and otherwise only when `setup` is set (`tp run --setup-solution`).
71
+ Mirrors `TraptaskLoader.from_task`.
72
+ """
73
+ from trap.git_ops import GitOpsError, ParsedGitUrl, RemoteRepo
74
+
75
+ # Each branch only resolves where trap.yaml lives (cloning a remote on the
76
+ # way); the loader is built once and setup runs once, below.
77
+ cwd = Path.cwd()
78
+ is_local_changed = False
79
+ if solution is None:
80
+ trap_yaml_path = cwd / "trap.yaml"
81
+ elif ParsedGitUrl.looks_remote(solution):
82
+ if not allow_remote:
83
+ raise GitOpsError("solution must be a local path here, not a remote URL")
84
+ parsed = ParsedGitUrl.from_full_url(solution)
85
+ # clone_to given → there; omitted → visible ./<repo>
86
+ dest = clone_to or Path(parsed.basename)
87
+ remote_repo = RemoteRepo(parsed, (cwd / dest).resolve())
88
+ is_local_changed = remote_repo.ensure(progress_func=progress_func)
89
+ trap_yaml_path = remote_repo.local_dir / "trap.yaml"
90
+ else:
91
+ if clone_to is not None:
92
+ raise GitOpsError("--clone-to only applies to a remote (git URL) solution")
93
+ trap_yaml_path = cwd / solution / "trap.yaml"
94
+
95
+ loader = cls(trap_yaml_path)
96
+ if (is_local_changed or setup) and loader.config.setup_cmd:
97
+ # raises subprocess.CalledProcessError on non-zero exit
98
+ subprocess.run(loader.config.setup_cmd, shell=True, cwd=loader.trap_dir, check=True)
99
+ return loader
@@ -0,0 +1,85 @@
1
+ # Loads traptask.yaml (task author's config) into TraptaskLoader.
2
+ from __future__ import annotations
3
+
4
+ import subprocess
5
+ from collections.abc import Iterable
6
+ from pathlib import Path
7
+
8
+ import yaml
9
+ from pydantic import ValidationError
10
+
11
+ from trap.loader.errors import ConfigError
12
+ from trap.models import TaskBinding, TraptaskCase, TraptaskConfig
13
+
14
+
15
+ class TraptaskLoader:
16
+ """Loads traptask.yaml (task author's config) and resolves runtime paths."""
17
+
18
+ def __init__(self, traptask_yaml_path: Path) -> None:
19
+ self.traptask_dir: Path = traptask_yaml_path.resolve().parent
20
+ if traptask_yaml_path.exists():
21
+ try:
22
+ data = yaml.safe_load(traptask_yaml_path.read_text())
23
+ except yaml.YAMLError as e:
24
+ raise ConfigError(f"invalid YAML in {traptask_yaml_path}: {e}") from e
25
+ try:
26
+ self.traptask = TraptaskConfig.model_validate(data)
27
+ except ValidationError as e:
28
+ raise ConfigError(f"invalid traptask.yaml ({traptask_yaml_path}):\n{e}") from e
29
+ else:
30
+ self.traptask = self._discover(self.traptask_dir)
31
+
32
+ @staticmethod
33
+ def _discover(traptask_dir: Path) -> TraptaskConfig:
34
+ """Auto-build TraptaskConfig by scanning inputs/ when traptask.yaml is absent."""
35
+ inputs_dir = traptask_dir / "inputs"
36
+ if not inputs_dir.is_dir():
37
+ raise ConfigError(f"no traptask.yaml and no inputs/ directory in {traptask_dir}")
38
+ case_ids = sorted(p.name for p in inputs_dir.iterdir() if p.is_dir())
39
+ if not case_ids:
40
+ raise ConfigError(f"inputs/ in {traptask_dir} has no case subdirectories")
41
+ return TraptaskConfig(cases=tuple(TraptaskCase(id=case_id) for case_id in case_ids))
42
+
43
+ @classmethod
44
+ def from_task(cls, task: TaskBinding, trap_dir: Path, setup: bool = False) -> TraptaskLoader:
45
+ """Resolve traptask.yaml from a TaskBinding's source and the trap.yaml directory.
46
+
47
+ Mirrors `TrapLoader.from_solution`: `source` is a local path or a git+ URL.
48
+ A URL clones into `clone_to` (omitted → hidden cache .trap/repos/<repo>,
49
+ since the task is a dependency); a local path uses it in place and rejects
50
+ `clone_to`. Raises GitOpsError on a bad spec (caller maps it to a CLI error).
51
+
52
+ The task's `setup_cmd` (declared in its traptask.yaml, so it travels with the
53
+ task version) prepares the checkout. It auto-runs when a remote pull brought
54
+ new code, and otherwise only when `setup` is set (the `tp run --setup-task`
55
+ escape hatch covering pinned/up-to-date clones and local sources).
56
+ """
57
+ from trap.git_ops import GitOpsError, ParsedGitUrl, RemoteRepo
58
+
59
+ if ParsedGitUrl.looks_remote(task.source):
60
+ parsed = ParsedGitUrl.from_full_url(task.source)
61
+ dest = task.clone_to or Path(".trap") / "repos" / parsed.basename
62
+ remote_repo = RemoteRepo(parsed, (trap_dir / dest).resolve())
63
+ is_local_changed = remote_repo.ensure()
64
+ traptask_dir = remote_repo.local_dir
65
+ else:
66
+ if task.clone_to is not None:
67
+ raise GitOpsError("clone_to only applies to a remote (git URL) source")
68
+ is_local_changed = False
69
+ traptask_dir = (trap_dir / task.source).resolve()
70
+ loader = cls(traptask_dir / "traptask.yaml")
71
+ if (is_local_changed or setup) and loader.traptask.setup_cmd:
72
+ # raises subprocess.CalledProcessError on non-zero exit
73
+ subprocess.run(loader.traptask.setup_cmd, shell=True, cwd=loader.traptask_dir, check=True)
74
+ return loader
75
+
76
+ @property
77
+ def cases(self) -> tuple[TraptaskCase, ...]:
78
+ """Return all non-skipped cases."""
79
+ return tuple(c for c in self.traptask.cases if not c.skip)
80
+
81
+ def cases_with_tags(self, tags: Iterable[str] | None = None) -> tuple[TraptaskCase, ...]:
82
+ """Return non-skipped cases matching any of the specified tags, or all cases if tags is empty/None."""
83
+ if not (tag_set := set(tags or ())):
84
+ return self.cases
85
+ return tuple(c for c in self.cases if not tag_set.isdisjoint(c.tags))
@@ -0,0 +1,36 @@
1
+ from __future__ import annotations
2
+
3
+ from .cost import CaseCost, ModelCost
4
+ from .environment import Cpu, Environment
5
+ from .provenance import GitProvenance, Provenance
6
+ from .report import ReportData
7
+ from .results import CaseResult
8
+ from .trap_yaml import Profile, TaskBinding, TrapConfig
9
+ from .traptask_yaml import (
10
+ DirsConfig,
11
+ GraderConfig,
12
+ JudgeConfig,
13
+ SubprocessConfig,
14
+ TraptaskCase,
15
+ TraptaskConfig,
16
+ )
17
+
18
+ __all__ = [
19
+ "CaseCost",
20
+ "CaseResult",
21
+ "Cpu",
22
+ "DirsConfig",
23
+ "Environment",
24
+ "GitProvenance",
25
+ "GraderConfig",
26
+ "JudgeConfig",
27
+ "ModelCost",
28
+ "Profile",
29
+ "Provenance",
30
+ "ReportData",
31
+ "SubprocessConfig",
32
+ "TaskBinding",
33
+ "TrapConfig",
34
+ "TraptaskCase",
35
+ "TraptaskConfig",
36
+ ]
trap/models/cost.py ADDED
@@ -0,0 +1,36 @@
1
+ from __future__ import annotations
2
+
3
+ from pydantic import BaseModel, computed_field
4
+
5
+
6
+ class ModelCost(BaseModel):
7
+ provider: str
8
+ model: str | None = None
9
+ prompt_tokens: int = 0
10
+ completion_tokens: int = 0
11
+ cost_usd: float = 0.0
12
+ calls: int = 0
13
+
14
+
15
+ class CaseCost(BaseModel):
16
+ by_model: list[ModelCost] = []
17
+
18
+ @computed_field
19
+ @property
20
+ def prompt_tokens(self) -> int:
21
+ return sum(u.prompt_tokens for u in self.by_model)
22
+
23
+ @computed_field
24
+ @property
25
+ def completion_tokens(self) -> int:
26
+ return sum(u.completion_tokens for u in self.by_model)
27
+
28
+ @computed_field
29
+ @property
30
+ def cost_usd(self) -> float:
31
+ return sum(u.cost_usd for u in self.by_model)
32
+
33
+ @computed_field
34
+ @property
35
+ def calls(self) -> int:
36
+ return sum(u.calls for u in self.by_model)
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+
6
+ class Cpu(BaseModel):
7
+ """CPU identity. `model` is the human brand string (py-cpuinfo `brand_raw`),
8
+ which psutil does not expose."""
9
+
10
+ model: str | None = None
11
+ cores_physical: int | None = None
12
+ cores_logical: int | None = None
13
+
14
+
15
+ class Environment(BaseModel):
16
+ """Machine runtime environment captured at `tp run` — a fastfetch-like subset
17
+ relevant to comparing runs across hosts. Every field is optional so a failed
18
+ probe degrades to None rather than aborting the run."""
19
+
20
+ os: str | None = None
21
+ kernel: str | None = None
22
+ arch: str | None = None
23
+ cpu: Cpu = Field(default_factory=Cpu)
24
+ memory_total_bytes: int | None = None
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+
6
+ class GitProvenance(BaseModel):
7
+ """Git origin of one checkout: {repo, commit}. Both None when the tree isn't a
8
+ clean, remote-backed git repo (see LocalRepo.provenance)."""
9
+
10
+ repo: str | None = None
11
+ commit: str | None = None
12
+
13
+
14
+ class Provenance(BaseModel):
15
+ """The two checkouts that fully reproduce a run: the solution under test and the
16
+ task it ran against. Re-clone both at their commit and everything else (cmd,
17
+ judge, fixtures, ...) is recovered from the checkouts."""
18
+
19
+ solution: GitProvenance = Field(default_factory=GitProvenance)
20
+ task: GitProvenance = Field(default_factory=GitProvenance)
trap/models/report.py ADDED
@@ -0,0 +1,61 @@
1
+ # Wire format for what `tp` writes to .trap/<task>/<ts>/report.json
2
+ # and POSTs to the trapstreet `/api/submit` endpoint.
3
+ #
4
+ # Reference: trapstreet/docs/scoring-and-metrics.md "Upload protocol".
5
+ from __future__ import annotations
6
+
7
+ from datetime import datetime
8
+ from typing import Any
9
+
10
+ from pydantic import BaseModel, Field
11
+
12
+ from trap import __version__
13
+ from trap.models.environment import Environment
14
+ from trap.models.provenance import Provenance
15
+ from trap.models.results import CaseResult
16
+ from trap.models.trap_yaml import Profile, TrapConfig
17
+
18
+
19
+ class ReportData(BaseModel):
20
+ """Top-level upload protocol envelope."""
21
+
22
+ # The (repo, commit) of both checkouts — the minimal seed to reproduce the run.
23
+ provenance: Provenance = Field(default_factory=Provenance)
24
+
25
+ cases_results: tuple[CaseResult, ...]
26
+ # Raw run-level grader output; None when no grader is configured.
27
+ grader_metrics: Any
28
+ started_at_utc: str
29
+ finished_at_utc: str
30
+
31
+ # Engine identity (model/framework). Self-reported from trap.yaml today, but the
32
+ # website consumes it from the report — never from trap.yaml directly — precisely
33
+ # because the source may change: a future version is expected to derive this by
34
+ # observing actual usage (e.g. the cost proxy) rather than trusting self-report.
35
+ # Routing it through the report keeps that swap invisible to downstream consumers.
36
+ profile: Profile = Field(default_factory=Profile)
37
+ # The trap build that produced this report (hatch-vcs version).
38
+ trap_version: str = __version__
39
+ # Host machine environment captured at run time; None when --no-environment.
40
+ environment: Environment | None = None
41
+
42
+ @classmethod
43
+ def from_run(
44
+ cls,
45
+ trap_config: TrapConfig,
46
+ cases_results: tuple[CaseResult, ...],
47
+ grader_metrics: Any,
48
+ started_at_utc: datetime,
49
+ finished_at_utc: datetime,
50
+ provenance: Provenance,
51
+ environment: Environment | None = None,
52
+ ) -> ReportData:
53
+ return cls(
54
+ provenance=provenance,
55
+ cases_results=cases_results,
56
+ grader_metrics=grader_metrics,
57
+ started_at_utc=started_at_utc.isoformat(timespec="seconds"),
58
+ finished_at_utc=finished_at_utc.isoformat(timespec="seconds"),
59
+ profile=trap_config.profile,
60
+ environment=environment,
61
+ )
trap/models/results.py ADDED
@@ -0,0 +1,17 @@
1
+ # Runtime result models produced by judge and grader subprocesses.
2
+ from __future__ import annotations
3
+
4
+ from typing import Any
5
+
6
+ from pydantic import BaseModel
7
+
8
+ from trap.models.cost import CaseCost
9
+
10
+
11
+ class CaseResult(BaseModel):
12
+ case_id: str
13
+ exit_code: int = 0
14
+ duration: float = 0.0 # seconds
15
+ # any JSON-serializable value; trap does not interpret this, grader does
16
+ metrics: Any
17
+ cost: CaseCost | None = None