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/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
|
trap/loader/__init__.py
ADDED
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)."""
|
trap/loader/trap_yaml.py
ADDED
|
@@ -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))
|
trap/models/__init__.py
ADDED
|
@@ -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
|