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/__init__.py
ADDED
trap/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.0.1'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 0, 1)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
trap/auth/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from trap.auth.client import ApiClient, ApiError
|
|
2
|
+
from trap.auth.login import BrowserProvider, TokenProvider
|
|
3
|
+
from trap.auth.oauth import OAuthCallbackServer
|
|
4
|
+
from trap.auth.store import DEFAULT_SERVER, AuthData, AuthStore
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"DEFAULT_SERVER",
|
|
8
|
+
"ApiClient",
|
|
9
|
+
"ApiError",
|
|
10
|
+
"AuthData",
|
|
11
|
+
"AuthStore",
|
|
12
|
+
"BrowserProvider",
|
|
13
|
+
"OAuthCallbackServer",
|
|
14
|
+
"TokenProvider",
|
|
15
|
+
]
|
trap/auth/client.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from functools import cached_property
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ApiError(Exception):
|
|
11
|
+
"""A trapstreet API call failed — bad status, unreachable server, or invalid token.
|
|
12
|
+
Carries a user-facing message; the CLI maps it to a clean error (no traceback)."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ApiClient:
|
|
16
|
+
"""Authenticated HTTP client for the trapstreet API."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, server: str, api_key: str, timeout: int = 30) -> None:
|
|
19
|
+
self._server = server.rstrip("/")
|
|
20
|
+
self._api_key = api_key
|
|
21
|
+
self._timeout = timeout
|
|
22
|
+
|
|
23
|
+
@cached_property
|
|
24
|
+
def _client(self) -> httpx.Client:
|
|
25
|
+
return httpx.Client(
|
|
26
|
+
base_url=self._server,
|
|
27
|
+
headers={
|
|
28
|
+
"authorization": f"Bearer {self._api_key}",
|
|
29
|
+
"content-type": "application/json",
|
|
30
|
+
},
|
|
31
|
+
timeout=self._timeout,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
def get_me(self) -> dict[str, Any]:
|
|
35
|
+
try:
|
|
36
|
+
resp = self._client.get("/api/me", timeout=10)
|
|
37
|
+
resp.raise_for_status()
|
|
38
|
+
return resp.json()
|
|
39
|
+
except httpx.HTTPStatusError as e:
|
|
40
|
+
if e.response.status_code == 401:
|
|
41
|
+
raise ApiError("token is invalid") from None
|
|
42
|
+
raise ApiError(f"server error ({e.response.status_code})") from None
|
|
43
|
+
except httpx.RequestError:
|
|
44
|
+
raise ApiError("server unreachable") from None
|
|
45
|
+
|
|
46
|
+
def submit(self, report_path: Path) -> dict[str, Any]:
|
|
47
|
+
# Content-addressed ingest: the task identity travels inside the report
|
|
48
|
+
# (provenance.task.{repo,commit}), not the URL — so no task_id path segment.
|
|
49
|
+
try:
|
|
50
|
+
resp = self._client.post("/api/submit", content=report_path.read_bytes())
|
|
51
|
+
resp.raise_for_status()
|
|
52
|
+
return resp.json()
|
|
53
|
+
except httpx.HTTPStatusError as e:
|
|
54
|
+
raise ApiError(f"http {e.response.status_code}: {e.response.text}") from None
|
|
55
|
+
except httpx.RequestError as e:
|
|
56
|
+
raise ApiError(f"connection error: {e}") from None
|
trap/auth/login.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from trap.auth.oauth import OAuthCallbackServer
|
|
4
|
+
from trap.auth.store import DEFAULT_SERVER, AuthData
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class TokenProvider:
|
|
8
|
+
def __init__(self, server: str, token: str) -> None:
|
|
9
|
+
self._server = server
|
|
10
|
+
self._token = token
|
|
11
|
+
|
|
12
|
+
@property
|
|
13
|
+
def pre_message(self) -> str:
|
|
14
|
+
return "authenticating with api_key"
|
|
15
|
+
|
|
16
|
+
def acquire(self) -> AuthData:
|
|
17
|
+
if not self._token:
|
|
18
|
+
raise ValueError("empty api_key")
|
|
19
|
+
return AuthData(server=self._server, api_key=self._token)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class BrowserProvider:
|
|
23
|
+
def __init__(self, server: str, timeout: int) -> None:
|
|
24
|
+
if server != DEFAULT_SERVER:
|
|
25
|
+
raise ValueError(
|
|
26
|
+
f"browser login is only supported on {DEFAULT_SERVER}. Use --with-token for custom servers."
|
|
27
|
+
)
|
|
28
|
+
self._cb = OAuthCallbackServer(server)
|
|
29
|
+
self._timeout = timeout
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def pre_message(self) -> str:
|
|
33
|
+
return f"opening [link={self._cb.auth_url}]{self._cb.auth_url}[/link]"
|
|
34
|
+
|
|
35
|
+
def acquire(self) -> AuthData:
|
|
36
|
+
if not self._cb.run(self._timeout):
|
|
37
|
+
raise TimeoutError(f"timed out after {self._timeout}s waiting for browser approval")
|
|
38
|
+
assert self._cb.auth_data is not None
|
|
39
|
+
return self._cb.auth_data
|
trap/auth/oauth.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import http.server
|
|
4
|
+
import socketserver
|
|
5
|
+
import threading
|
|
6
|
+
import urllib.parse
|
|
7
|
+
import webbrowser
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from trap.auth.store import AuthData
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class _CallbackHandler(http.server.BaseHTTPRequestHandler):
|
|
14
|
+
_SUCCESS_HTML = (
|
|
15
|
+
b"<!doctype html><meta charset=utf-8>"
|
|
16
|
+
b"<title>logged in</title>"
|
|
17
|
+
b"<style>body{font-family:ui-monospace,monospace;"
|
|
18
|
+
b"background:#0a0a0a;color:#ededed;padding:3em;}"
|
|
19
|
+
b"h1{color:#ff5f1f}</style>"
|
|
20
|
+
b"<h1>logged in</h1>"
|
|
21
|
+
b"<p>You can close this tab.</p>"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
def log_message(self, format: str, *args: Any) -> None:
|
|
25
|
+
return
|
|
26
|
+
|
|
27
|
+
def _respond(self, status: int, content_type: str, body: bytes) -> None:
|
|
28
|
+
self.send_response(status)
|
|
29
|
+
self.send_header("content-type", content_type)
|
|
30
|
+
self.end_headers()
|
|
31
|
+
self.wfile.write(body)
|
|
32
|
+
|
|
33
|
+
def do_GET(self) -> None:
|
|
34
|
+
params = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
|
|
35
|
+
|
|
36
|
+
api_key = (params.get("api_key") or [None])[0]
|
|
37
|
+
if not api_key:
|
|
38
|
+
self._respond(400, "text/plain", b"missing api_key in query string")
|
|
39
|
+
return
|
|
40
|
+
|
|
41
|
+
solution = (params.get("solution") or [None])[0]
|
|
42
|
+
srv: OAuthCallbackServer = self.server # type: ignore[assignment]
|
|
43
|
+
srv._receive(AuthData(server=srv._server_url, api_key=api_key, solution=solution))
|
|
44
|
+
self._respond(200, "text/html; charset=utf-8", self._SUCCESS_HTML)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class OAuthCallbackServer(socketserver.TCPServer):
|
|
48
|
+
"""Local HTTP server that receives the OAuth callback with api_key."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, server_url: str) -> None:
|
|
51
|
+
super().__init__(("127.0.0.1", 0), _CallbackHandler)
|
|
52
|
+
self._server_url = server_url.rstrip("/")
|
|
53
|
+
self._auth_data: AuthData | None = None
|
|
54
|
+
self._received = threading.Event()
|
|
55
|
+
|
|
56
|
+
def _receive(self, data: AuthData) -> None:
|
|
57
|
+
self._auth_data = data
|
|
58
|
+
self._received.set()
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def auth_data(self) -> AuthData | None:
|
|
62
|
+
return self._auth_data
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def port(self) -> int:
|
|
66
|
+
return self.server_address[1]
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def auth_url(self) -> str:
|
|
70
|
+
return f"{self._server_url}/cli/authorize?return=http://localhost:{self.port}/callback"
|
|
71
|
+
|
|
72
|
+
def run(self, timeout: int) -> bool:
|
|
73
|
+
"""Start server, open browser, wait for callback, shut down. Returns True if api_key received."""
|
|
74
|
+
threading.Thread(target=self.serve_forever, daemon=True).start()
|
|
75
|
+
try:
|
|
76
|
+
webbrowser.open(self.auth_url)
|
|
77
|
+
except Exception:
|
|
78
|
+
pass
|
|
79
|
+
received = self._received.wait(timeout)
|
|
80
|
+
self.shutdown()
|
|
81
|
+
self.server_close()
|
|
82
|
+
return received
|
trap/auth/store.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json as _json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, ValidationError
|
|
7
|
+
|
|
8
|
+
DEFAULT_SERVER = "https://trapstreet.run"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AuthData(BaseModel):
|
|
12
|
+
server: str
|
|
13
|
+
api_key: str
|
|
14
|
+
solution: str | None = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AuthStore:
|
|
18
|
+
PATH = Path.home() / ".config" / "trapstreet" / "auth.json"
|
|
19
|
+
|
|
20
|
+
def load(self) -> AuthData | None:
|
|
21
|
+
if not self.exists:
|
|
22
|
+
return None
|
|
23
|
+
try:
|
|
24
|
+
return AuthData.model_validate(_json.loads(self.PATH.read_text()))
|
|
25
|
+
except (OSError, _json.JSONDecodeError, ValidationError):
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
def save(self, data: AuthData) -> Path:
|
|
29
|
+
self.PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
self.PATH.write_text(data.model_dump_json(exclude_none=True, indent=2) + "\n")
|
|
31
|
+
self.PATH.chmod(0o600)
|
|
32
|
+
return self.PATH
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def exists(self) -> bool:
|
|
36
|
+
return self.PATH.exists()
|
|
37
|
+
|
|
38
|
+
def delete(self) -> None:
|
|
39
|
+
self.PATH.unlink()
|
trap/cli/__init__.py
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Annotated
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
|
|
13
|
+
from trap.auth import DEFAULT_SERVER, ApiClient, ApiError, AuthStore
|
|
14
|
+
from trap.cli._auth import auth_app
|
|
15
|
+
from trap.display import CaseProgress, render_submit_result
|
|
16
|
+
from trap.environment import EnvironmentDetector
|
|
17
|
+
from trap.git_ops import GitOpsError, LocalRepo, ParsedGitUrl
|
|
18
|
+
from trap.loader import ConfigError, TrapLoader, TraptaskLoader
|
|
19
|
+
from trap.models import Provenance
|
|
20
|
+
from trap.report import OutputFormat, ReportHandle, renderer_factory
|
|
21
|
+
from trap.runner import TaskRunner
|
|
22
|
+
|
|
23
|
+
app = typer.Typer(help="AI prompt / agent / workflow / testing framework.")
|
|
24
|
+
app.add_typer(auth_app, name="auth")
|
|
25
|
+
console = Console()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _die(msg: object) -> typer.Exit:
|
|
29
|
+
"""Print an error and return an Exit(2) to raise."""
|
|
30
|
+
console.print(f"[red]error[/red]: {msg}")
|
|
31
|
+
return typer.Exit(code=2)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _env_truthy(name: str) -> bool:
|
|
35
|
+
return os.environ.get(name, "").strip().lower() in ("1", "true", "yes", "on")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _confirm_remote(url: str, *, trust: bool) -> None:
|
|
39
|
+
"""Gate trap's auto-download-and-run of a remote (git+ URL) source — the one place
|
|
40
|
+
trap fetches and executes code the user may not have seen. Returns to proceed;
|
|
41
|
+
raises to abort. Bypassed by --trust-remote / TRAP_TRUST_REMOTE (for CI / repeated
|
|
42
|
+
runs); refuses (rather than running silently) when there's no TTY to confirm at."""
|
|
43
|
+
if trust:
|
|
44
|
+
return
|
|
45
|
+
console.print(
|
|
46
|
+
"[yellow]⚠ about to download and RUN code from a remote source:[/yellow]\n"
|
|
47
|
+
f" [bold]{url}[/bold]\n"
|
|
48
|
+
" trap will execute its setup command, the solution, and any judge/grader\n"
|
|
49
|
+
" scripts — arbitrary code from this repo runs on your machine."
|
|
50
|
+
)
|
|
51
|
+
if not sys.stdin.isatty():
|
|
52
|
+
raise _die(
|
|
53
|
+
"remote source needs confirmation; pass --trust-remote "
|
|
54
|
+
"(or set TRAP_TRUST_REMOTE=1) to run it non-interactively"
|
|
55
|
+
)
|
|
56
|
+
if not typer.confirm("Continue?", default=False):
|
|
57
|
+
raise typer.Exit(code=1)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@app.command()
|
|
61
|
+
def run(
|
|
62
|
+
task: Annotated[str | None, typer.Argument()] = None,
|
|
63
|
+
solution: Annotated[
|
|
64
|
+
str | None,
|
|
65
|
+
typer.Option("--solution", help="Solution to run: a local path or a git+ URL (default: cwd)."),
|
|
66
|
+
] = None,
|
|
67
|
+
clone_to: Annotated[
|
|
68
|
+
Path | None,
|
|
69
|
+
typer.Option("--clone-to", help="Where to clone a git+ URL --solution (default: ./<repo>)."),
|
|
70
|
+
] = None,
|
|
71
|
+
trust_remote: Annotated[
|
|
72
|
+
bool,
|
|
73
|
+
typer.Option(
|
|
74
|
+
"--trust-remote",
|
|
75
|
+
help="Skip the confirmation before downloading and running a remote "
|
|
76
|
+
"(git+ URL) solution/task. Also settable via TRAP_TRUST_REMOTE=1.",
|
|
77
|
+
),
|
|
78
|
+
] = False,
|
|
79
|
+
tags: Annotated[list[str] | None, typer.Option("--tag", "-t")] = None,
|
|
80
|
+
output: Annotated[OutputFormat, typer.Option("--output", "-o")] = OutputFormat.rich,
|
|
81
|
+
fail_fast: Annotated[bool, typer.Option("--fail-fast")] = False,
|
|
82
|
+
setup_solution: Annotated[
|
|
83
|
+
bool,
|
|
84
|
+
typer.Option(
|
|
85
|
+
"--setup-solution",
|
|
86
|
+
help="Force-run the solution's setup_cmd even when no remote pull brought new code.",
|
|
87
|
+
),
|
|
88
|
+
] = False,
|
|
89
|
+
setup_task: Annotated[
|
|
90
|
+
bool,
|
|
91
|
+
typer.Option(
|
|
92
|
+
"--setup-task",
|
|
93
|
+
help="Force-run the task's setup_cmd even when no remote pull brought new code.",
|
|
94
|
+
),
|
|
95
|
+
] = False,
|
|
96
|
+
workspace: Annotated[Path, typer.Option("--workspace", "-w")] = Path(".trap"),
|
|
97
|
+
environment: Annotated[
|
|
98
|
+
bool,
|
|
99
|
+
typer.Option(
|
|
100
|
+
"--environment/--no-environment",
|
|
101
|
+
help="Collect host machine environment info (CPU/RAM/OS/Python) into the report.",
|
|
102
|
+
),
|
|
103
|
+
] = True,
|
|
104
|
+
cost: Annotated[
|
|
105
|
+
bool,
|
|
106
|
+
typer.Option(
|
|
107
|
+
"--cost/--no-cost",
|
|
108
|
+
help="Track LLM token usage and spend via the proxy (auto-detects providers from env).",
|
|
109
|
+
),
|
|
110
|
+
] = True,
|
|
111
|
+
) -> None:
|
|
112
|
+
"""Run a task against a solution.
|
|
113
|
+
|
|
114
|
+
--solution is the solution to run: a local path, or a git+ URL to clone
|
|
115
|
+
into ./<repo> (or --clone-to). Omit it to use the trap.yaml in the cwd.
|
|
116
|
+
"""
|
|
117
|
+
# Gate trap's auto-download-and-run of any remote source before it happens — once
|
|
118
|
+
# for a remote --solution, once for a remote task source (resolved from trap.yaml).
|
|
119
|
+
trust = trust_remote or _env_truthy("TRAP_TRUST_REMOTE")
|
|
120
|
+
if solution is not None and ParsedGitUrl.looks_remote(solution):
|
|
121
|
+
_confirm_remote(solution, trust=trust)
|
|
122
|
+
try:
|
|
123
|
+
trap_yaml_loader = TrapLoader.from_solution(
|
|
124
|
+
solution,
|
|
125
|
+
clone_to,
|
|
126
|
+
allow_remote=True,
|
|
127
|
+
setup=setup_solution,
|
|
128
|
+
progress_func=(
|
|
129
|
+
(lambda m: console.print(f"[dim]{m}[/dim]")) if output == OutputFormat.rich else None
|
|
130
|
+
),
|
|
131
|
+
)
|
|
132
|
+
task_binding = trap_yaml_loader.resolve_task(task)
|
|
133
|
+
if ParsedGitUrl.looks_remote(task_binding.source):
|
|
134
|
+
_confirm_remote(task_binding.source, trust=trust)
|
|
135
|
+
traptask_yaml_loader = TraptaskLoader.from_task(
|
|
136
|
+
task_binding, trap_yaml_loader.trap_dir, setup=setup_task
|
|
137
|
+
)
|
|
138
|
+
except (GitOpsError, ConfigError, subprocess.CalledProcessError) as e:
|
|
139
|
+
raise _die(e) from None
|
|
140
|
+
|
|
141
|
+
active_cases = traptask_yaml_loader.cases_with_tags(tags or [])
|
|
142
|
+
|
|
143
|
+
started_at_local = datetime.now()
|
|
144
|
+
ts = started_at_local.isoformat(timespec="seconds")
|
|
145
|
+
report_handle = ReportHandle(workspace.resolve(), task_binding.alias, ts)
|
|
146
|
+
|
|
147
|
+
runner = TaskRunner(
|
|
148
|
+
trap_config=trap_yaml_loader.config,
|
|
149
|
+
trap_dir=trap_yaml_loader.trap_dir,
|
|
150
|
+
traptask_config=traptask_yaml_loader.traptask,
|
|
151
|
+
traptask_dir=traptask_yaml_loader.traptask_dir,
|
|
152
|
+
run_dir=report_handle.run_dir,
|
|
153
|
+
cost_enabled=cost,
|
|
154
|
+
)
|
|
155
|
+
prog_console = console if output == OutputFormat.rich else None
|
|
156
|
+
with CaseProgress(active_cases, console=prog_console) as prog:
|
|
157
|
+
case_results, grader_metrics = runner.run(
|
|
158
|
+
active_cases,
|
|
159
|
+
fail_fast=fail_fast,
|
|
160
|
+
on_case_start=prog.on_case_start,
|
|
161
|
+
on_case_done=prog.on_case_done,
|
|
162
|
+
)
|
|
163
|
+
finished_at_utc = datetime.now(UTC)
|
|
164
|
+
|
|
165
|
+
# Record git provenance (repo + commit) of both checkouts — solution and task —
|
|
166
|
+
# so the run is reproducible. Each side is empty for a dirty/remote-less/local tree.
|
|
167
|
+
provenance = Provenance(
|
|
168
|
+
solution=LocalRepo.provenance_of(trap_yaml_loader.trap_dir),
|
|
169
|
+
task=LocalRepo.provenance_of(traptask_yaml_loader.traptask_dir),
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
# Capture the host machine environment (CPU/RAM/OS/Python) unless disabled.
|
|
173
|
+
# Detection is best-effort and must never abort a completed run.
|
|
174
|
+
environment_info = None
|
|
175
|
+
if environment:
|
|
176
|
+
try:
|
|
177
|
+
environment_info = EnvironmentDetector().detect()
|
|
178
|
+
except Exception:
|
|
179
|
+
environment_info = None
|
|
180
|
+
|
|
181
|
+
report_data = report_handle.save(
|
|
182
|
+
case_results=case_results,
|
|
183
|
+
trap_config=trap_yaml_loader.config,
|
|
184
|
+
started_at_utc=started_at_local.astimezone(UTC),
|
|
185
|
+
finished_at_utc=finished_at_utc,
|
|
186
|
+
grader_metrics=grader_metrics,
|
|
187
|
+
provenance=provenance,
|
|
188
|
+
environment=environment_info,
|
|
189
|
+
)
|
|
190
|
+
renderer_factory(output).render(report_data)
|
|
191
|
+
# trap reports facts, not a verdict: a completed run exits 0 regardless of per-case
|
|
192
|
+
# exit codes or scores (read the grader / report.json to gate CI). trap-level
|
|
193
|
+
# failures (bad config, git errors) still exit non-zero via _die.
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@app.command()
|
|
197
|
+
def report(
|
|
198
|
+
task: Annotated[str | None, typer.Argument()] = None,
|
|
199
|
+
run: Annotated[str, typer.Argument()] = "latest",
|
|
200
|
+
solution: Annotated[
|
|
201
|
+
str | None,
|
|
202
|
+
typer.Option("--solution", help="Local solution path holding trap.yaml (default: cwd)."),
|
|
203
|
+
] = None,
|
|
204
|
+
output: Annotated[OutputFormat, typer.Option("--output", "-o")] = OutputFormat.rich,
|
|
205
|
+
workspace: Annotated[Path, typer.Option("--workspace", "-w")] = Path(".trap"),
|
|
206
|
+
) -> None:
|
|
207
|
+
"""Display a report for a task (defaults to latest run)."""
|
|
208
|
+
try:
|
|
209
|
+
task_alias = TrapLoader.from_solution(solution).resolve_task(task).alias
|
|
210
|
+
except (GitOpsError, ConfigError) as e:
|
|
211
|
+
raise _die(e) from None
|
|
212
|
+
handle = ReportHandle(workspace.resolve(), task_alias, run)
|
|
213
|
+
try:
|
|
214
|
+
report_data = handle.load()
|
|
215
|
+
except FileNotFoundError:
|
|
216
|
+
raise _die(f"no report at {handle.report_json_path}; run `tp run` first") from None
|
|
217
|
+
renderer_factory(output).render(report_data)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@app.command()
|
|
221
|
+
def submit(
|
|
222
|
+
task: Annotated[
|
|
223
|
+
str | None,
|
|
224
|
+
typer.Argument(
|
|
225
|
+
help="Task name (defaults to first task in trap.yaml). "
|
|
226
|
+
"Used as both the local run dir and the trapstreet task_id.",
|
|
227
|
+
),
|
|
228
|
+
] = None,
|
|
229
|
+
solution: Annotated[
|
|
230
|
+
str | None,
|
|
231
|
+
typer.Option("--solution", help="Local solution path holding trap.yaml (default: cwd)."),
|
|
232
|
+
] = None,
|
|
233
|
+
workspace: Annotated[Path, typer.Option("--workspace", "-w")] = Path(".trap"),
|
|
234
|
+
run: Annotated[str, typer.Option("--run", "-r", help="Which run to upload.")] = "latest",
|
|
235
|
+
) -> None:
|
|
236
|
+
"""Upload a report.json to trapstreet.
|
|
237
|
+
|
|
238
|
+
Reads from the .trap/<task>/<run>/report.json workspace that `tp run`
|
|
239
|
+
populated.
|
|
240
|
+
"""
|
|
241
|
+
stored = AuthStore().load()
|
|
242
|
+
# priority: TRAPSTREET_URL env > stored > default
|
|
243
|
+
resolved_server = (
|
|
244
|
+
os.environ.get("TRAPSTREET_URL") or (stored.server if stored else None) or DEFAULT_SERVER
|
|
245
|
+
)
|
|
246
|
+
# priority: TRAPSTREET_API_KEY env > stored
|
|
247
|
+
resolved_key = os.environ.get("TRAPSTREET_API_KEY") or (stored.api_key if stored else None)
|
|
248
|
+
if not resolved_key:
|
|
249
|
+
raise _die("not logged in. Run [bold]tp auth login[/bold] or set [bold]TRAPSTREET_API_KEY[/bold].")
|
|
250
|
+
|
|
251
|
+
try:
|
|
252
|
+
task_alias = TrapLoader.from_solution(solution).resolve_task(task).alias
|
|
253
|
+
except (GitOpsError, ConfigError) as e:
|
|
254
|
+
raise _die(e) from None
|
|
255
|
+
report_handle = ReportHandle(workspace.resolve(), task_alias, run)
|
|
256
|
+
try:
|
|
257
|
+
report_handle.assert_exists()
|
|
258
|
+
except FileNotFoundError:
|
|
259
|
+
raise _die(f"no report at {report_handle.report_json_path}. Run [bold]tp run[/bold] first.") from None
|
|
260
|
+
report_path = report_handle.report_json_path
|
|
261
|
+
|
|
262
|
+
client = ApiClient(resolved_server, resolved_key)
|
|
263
|
+
try:
|
|
264
|
+
resp_data = client.submit(report_path)
|
|
265
|
+
except ApiError as e:
|
|
266
|
+
raise _die(e) from None
|
|
267
|
+
render_submit_result(resp_data)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
# Hidden until the scaffold is implemented — registered but not advertised in `--help`.
|
|
271
|
+
@app.command(hidden=True)
|
|
272
|
+
def init() -> None:
|
|
273
|
+
"""Generate annotated trap.yaml + traptask.yaml scaffold."""
|
|
274
|
+
console.print("not yet")
|
trap/cli/_auth.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
|
|
9
|
+
from trap.auth import DEFAULT_SERVER, ApiClient, ApiError, AuthStore, BrowserProvider, TokenProvider
|
|
10
|
+
|
|
11
|
+
auth_app = typer.Typer(help="Manage authentication.")
|
|
12
|
+
console = Console()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@auth_app.command("login")
|
|
16
|
+
def auth_login(
|
|
17
|
+
server: Annotated[
|
|
18
|
+
str | None,
|
|
19
|
+
typer.Option("--server", envvar="TRAPSTREET_URL", help="Trapstreet server URL."),
|
|
20
|
+
] = None,
|
|
21
|
+
timeout: Annotated[int, typer.Option("--timeout", help="Seconds to wait for browser approval.")] = 300,
|
|
22
|
+
with_token: Annotated[
|
|
23
|
+
bool,
|
|
24
|
+
typer.Option("--with-token", help="Read api_key from stdin instead of opening a browser."),
|
|
25
|
+
] = False,
|
|
26
|
+
) -> None:
|
|
27
|
+
"""Authenticate this machine with Trapstreet.
|
|
28
|
+
|
|
29
|
+
By default opens a browser to complete OAuth. Pass --with-token to supply
|
|
30
|
+
an api_key directly (useful for CI or headless environments).
|
|
31
|
+
|
|
32
|
+
The token is saved to ~/.config/trapstreet/auth.json (mode 600).
|
|
33
|
+
"""
|
|
34
|
+
stored = AuthStore().load()
|
|
35
|
+
# priority: --server / TRAPSTREET_URL > stored > default
|
|
36
|
+
resolved_server = server or (stored.server if stored else None) or DEFAULT_SERVER
|
|
37
|
+
|
|
38
|
+
# BrowserProvider raises ValueError for a non-default server (the same check the
|
|
39
|
+
# CLI used to duplicate), so provider construction sits inside the try alongside
|
|
40
|
+
# acquire() — one error path for both.
|
|
41
|
+
try:
|
|
42
|
+
if with_token:
|
|
43
|
+
if sys.stdin.isatty(): # pragma: no cover - interactive prompt, no TTY under tests
|
|
44
|
+
token = typer.prompt("API key", hide_input=True)
|
|
45
|
+
else:
|
|
46
|
+
token = sys.stdin.read().strip()
|
|
47
|
+
provider = TokenProvider(resolved_server, token)
|
|
48
|
+
else:
|
|
49
|
+
provider = BrowserProvider(resolved_server, timeout)
|
|
50
|
+
console.print(provider.pre_message)
|
|
51
|
+
auth_data = provider.acquire()
|
|
52
|
+
except (ValueError, TimeoutError) as e:
|
|
53
|
+
console.print(f"[red]error[/red]: {e}")
|
|
54
|
+
raise typer.Exit(code=2) from None
|
|
55
|
+
|
|
56
|
+
path = AuthStore().save(auth_data)
|
|
57
|
+
console.print(
|
|
58
|
+
"[green]✓ logged in[/green]"
|
|
59
|
+
+ (f" · solution [bold]{auth_data.solution}[/bold]" if auth_data.solution else "")
|
|
60
|
+
+ f" · token saved to {path}"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@auth_app.command("logout")
|
|
65
|
+
def auth_logout() -> None:
|
|
66
|
+
"""Delete the locally-stored api_key."""
|
|
67
|
+
auth_store = AuthStore()
|
|
68
|
+
if not auth_store.exists:
|
|
69
|
+
console.print(f"already logged out — no file at {auth_store.PATH}")
|
|
70
|
+
return
|
|
71
|
+
auth_store.delete()
|
|
72
|
+
console.print(f"[green]✓[/green] removed {auth_store.PATH}")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@auth_app.command("status")
|
|
76
|
+
def auth_status(
|
|
77
|
+
verify: Annotated[
|
|
78
|
+
bool,
|
|
79
|
+
typer.Option("--verify/--no-verify", help="Ping server to verify token validity."),
|
|
80
|
+
] = True,
|
|
81
|
+
) -> None:
|
|
82
|
+
"""Show current authentication state."""
|
|
83
|
+
stored = AuthStore().load()
|
|
84
|
+
if not stored:
|
|
85
|
+
console.print("[red]not logged in[/red]. Run [bold]tp auth login[/bold].")
|
|
86
|
+
raise typer.Exit(code=1)
|
|
87
|
+
|
|
88
|
+
console.print(f" server {stored.server}")
|
|
89
|
+
if stored.solution:
|
|
90
|
+
console.print(f" solution [bold]{stored.solution}[/bold]")
|
|
91
|
+
|
|
92
|
+
if not verify:
|
|
93
|
+
return
|
|
94
|
+
|
|
95
|
+
client = ApiClient(stored.server, stored.api_key)
|
|
96
|
+
try:
|
|
97
|
+
me = client.get_me()
|
|
98
|
+
except ApiError as e:
|
|
99
|
+
console.print(f"[red]error[/red]: {e}")
|
|
100
|
+
raise typer.Exit(code=1) from None
|
|
101
|
+
user = me.get("user") or {}
|
|
102
|
+
identity = user.get("name") or user.get("email") or "(unknown)"
|
|
103
|
+
console.print(f" user {identity}\n[green]✓ token is valid[/green]")
|
trap/cost/__init__.py
ADDED