trap-cli 0.0.1__tar.gz → 0.0.3__tar.gz

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 (56) hide show
  1. {trap_cli-0.0.1 → trap_cli-0.0.3}/PKG-INFO +1 -1
  2. {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/_version.py +2 -2
  3. {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/auth/client.py +2 -1
  4. {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/cli/__init__.py +68 -9
  5. trap_cli-0.0.3/src/trap/git_ops/local.py +86 -0
  6. {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/models/provenance.py +8 -2
  7. {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/models/report.py +4 -0
  8. trap_cli-0.0.1/src/trap/git_ops/local.py +0 -60
  9. {trap_cli-0.0.1 → trap_cli-0.0.3}/.gitignore +0 -0
  10. {trap_cli-0.0.1 → trap_cli-0.0.3}/LICENSE +0 -0
  11. {trap_cli-0.0.1 → trap_cli-0.0.3}/README.md +0 -0
  12. {trap_cli-0.0.1 → trap_cli-0.0.3}/examples/echo/task/README.md +0 -0
  13. {trap_cli-0.0.1 → trap_cli-0.0.3}/pyproject.toml +0 -0
  14. {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/__init__.py +0 -0
  15. {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/auth/__init__.py +0 -0
  16. {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/auth/login.py +0 -0
  17. {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/auth/oauth.py +0 -0
  18. {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/auth/store.py +0 -0
  19. {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/cli/_auth.py +0 -0
  20. {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/cost/__init__.py +0 -0
  21. {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/cost/calculator.py +0 -0
  22. {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/cost/providers.py +0 -0
  23. {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/cost/proxy.py +0 -0
  24. {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/display/__init__.py +0 -0
  25. {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/display/progress.py +0 -0
  26. {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/display/submit.py +0 -0
  27. {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/environment/__init__.py +0 -0
  28. {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/environment/detector.py +0 -0
  29. {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/git_ops/__init__.py +0 -0
  30. {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/git_ops/base.py +0 -0
  31. {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/git_ops/remote.py +0 -0
  32. {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/git_ops/rev.py +0 -0
  33. {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/git_ops/url.py +0 -0
  34. {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/loader/__init__.py +0 -0
  35. {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/loader/errors.py +0 -0
  36. {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/loader/trap_yaml.py +0 -0
  37. {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/loader/traptask_yaml.py +0 -0
  38. {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/models/__init__.py +0 -0
  39. {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/models/cost.py +0 -0
  40. {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/models/environment.py +0 -0
  41. {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/models/results.py +0 -0
  42. {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/models/trap_yaml.py +0 -0
  43. {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/models/traptask_yaml.py +0 -0
  44. {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/report/__init__.py +0 -0
  45. {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/report/base.py +0 -0
  46. {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/report/handle.py +0 -0
  47. {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/report/json.py +0 -0
  48. {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/report/rich.py +0 -0
  49. {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/runner/__init__.py +0 -0
  50. {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/runner/capture.py +0 -0
  51. {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/runner/grader.py +0 -0
  52. {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/runner/judge.py +0 -0
  53. {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/runner/layout.py +0 -0
  54. {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/runner/proc.py +0 -0
  55. {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/runner/solution.py +0 -0
  56. {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/runner/task.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: trap-cli
3
- Version: 0.0.1
3
+ Version: 0.0.3
4
4
  Summary: tp — non-invasive CLI testing framework for AI workflows. Submits results to trapstreet.run.
5
5
  Project-URL: Homepage, https://trapstreet.run
6
6
  Project-URL: Repository, https://github.com/trapstreet/trap
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.0.1'
22
- __version_tuple__ = version_tuple = (0, 0, 1)
21
+ __version__ = version = '0.0.3'
22
+ __version_tuple__ = version_tuple = (0, 0, 3)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -45,7 +45,8 @@ class ApiClient:
45
45
 
46
46
  def submit(self, report_path: Path) -> dict[str, Any]:
47
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.
48
+ # (provenance.task.{repo,commit,subdirectory}), not the URL — so no task_id
49
+ # path segment.
49
50
  try:
50
51
  resp = self._client.post("/api/submit", content=report_path.read_bytes())
51
52
  resp.raise_for_status()
@@ -23,6 +23,8 @@ from trap.runner import TaskRunner
23
23
  app = typer.Typer(help="AI prompt / agent / workflow / testing framework.")
24
24
  app.add_typer(auth_app, name="auth")
25
25
  console = Console()
26
+ # Warnings go to stderr so `-o json` stdout stays machine-parseable.
27
+ err_console = Console(stderr=True)
26
28
 
27
29
 
28
30
  def _die(msg: object) -> typer.Exit:
@@ -42,7 +44,7 @@ def _confirm_remote(url: str, *, trust: bool) -> None:
42
44
  runs); refuses (rather than running silently) when there's no TTY to confirm at."""
43
45
  if trust:
44
46
  return
45
- console.print(
47
+ err_console.print(
46
48
  "[yellow]⚠ about to download and RUN code from a remote source:[/yellow]\n"
47
49
  f" [bold]{url}[/bold]\n"
48
50
  " trap will execute its setup command, the solution, and any judge/grader\n"
@@ -57,6 +59,39 @@ def _confirm_remote(url: str, *, trust: bool) -> None:
57
59
  raise typer.Exit(code=1)
58
60
 
59
61
 
62
+ def _confirm_unanchored(provenance: Provenance, *, allow: bool) -> None:
63
+ """Gate run/submit when a side has no anchored git provenance.
64
+
65
+ trapstreet accepts such uploads but its leaderboard hides runs it cannot pin to
66
+ a commit — make the user acknowledge that up front instead of discovering it on
67
+ the site. The reason travels in the provenance's `issue` field, so `tp submit`
68
+ (which reads the saved report rather than re-probing the checkouts) names it
69
+ too. `allow` (--allow-unanchored / TRAP_ALLOW_UNANCHORED, for CI) keeps the
70
+ warning but skips the prompt; with no TTY and no `allow` it refuses.
71
+ """
72
+ sides = {"solution": provenance.solution, "task": provenance.task}
73
+ missing = {name: side.issue for name, side in sides.items() if not side.repo}
74
+ if not missing:
75
+ return
76
+ for name, reason in missing.items():
77
+ suffix = f" ({reason})" if reason else ""
78
+ err_console.print(f"[yellow]⚠ {name} has no git provenance{suffix}[/yellow]")
79
+ err_console.print(
80
+ "[yellow] trapstreet accepts the submission, but the leaderboard hides runs that "
81
+ "aren't anchored to a commit on a remote — run from a clean, committed checkout "
82
+ "with an origin remote to make it rankable.[/yellow]"
83
+ )
84
+ if allow:
85
+ return
86
+ if not sys.stdin.isatty():
87
+ raise _die(
88
+ "unanchored provenance needs confirmation; pass --allow-unanchored "
89
+ "(or set TRAP_ALLOW_UNANCHORED=1) to proceed non-interactively"
90
+ )
91
+ if not typer.confirm("Continue anyway?", default=False):
92
+ raise typer.Exit(code=1)
93
+
94
+
60
95
  @app.command()
61
96
  def run(
62
97
  task: Annotated[str | None, typer.Argument()] = None,
@@ -76,6 +111,14 @@ def run(
76
111
  "(git+ URL) solution/task. Also settable via TRAP_TRUST_REMOTE=1.",
77
112
  ),
78
113
  ] = False,
114
+ allow_unanchored: Annotated[
115
+ bool,
116
+ typer.Option(
117
+ "--allow-unanchored",
118
+ help="Skip the confirmation when the run has no git provenance (the "
119
+ "leaderboard hides such runs). Also settable via TRAP_ALLOW_UNANCHORED=1.",
120
+ ),
121
+ ] = False,
79
122
  tags: Annotated[list[str] | None, typer.Option("--tag", "-t")] = None,
80
123
  output: Annotated[OutputFormat, typer.Option("--output", "-o")] = OutputFormat.rich,
81
124
  fail_fast: Annotated[bool, typer.Option("--fail-fast")] = False,
@@ -138,6 +181,16 @@ def run(
138
181
  except (GitOpsError, ConfigError, subprocess.CalledProcessError) as e:
139
182
  raise _die(e) from None
140
183
 
184
+ # Record git provenance (repo + commit) of both checkouts — solution and task —
185
+ # so the run is reproducible; an unanchored side carries an `issue` naming why.
186
+ # Probed once, before any case runs: an unanchored checkout produces a report the
187
+ # leaderboard will hide, so the user must acknowledge (or pre-authorise) that.
188
+ provenance = Provenance(
189
+ solution=LocalRepo.provenance_of(trap_yaml_loader.trap_dir),
190
+ task=LocalRepo.provenance_of(traptask_yaml_loader.traptask_dir),
191
+ )
192
+ _confirm_unanchored(provenance, allow=allow_unanchored or _env_truthy("TRAP_ALLOW_UNANCHORED"))
193
+
141
194
  active_cases = traptask_yaml_loader.cases_with_tags(tags or [])
142
195
 
143
196
  started_at_local = datetime.now()
@@ -162,13 +215,6 @@ def run(
162
215
  )
163
216
  finished_at_utc = datetime.now(UTC)
164
217
 
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
218
  # Capture the host machine environment (CPU/RAM/OS/Python) unless disabled.
173
219
  # Detection is best-effort and must never abort a completed run.
174
220
  environment_info = None
@@ -232,6 +278,14 @@ def submit(
232
278
  ] = None,
233
279
  workspace: Annotated[Path, typer.Option("--workspace", "-w")] = Path(".trap"),
234
280
  run: Annotated[str, typer.Option("--run", "-r", help="Which run to upload.")] = "latest",
281
+ allow_unanchored: Annotated[
282
+ bool,
283
+ typer.Option(
284
+ "--allow-unanchored",
285
+ help="Skip the confirmation when the run has no git provenance (the "
286
+ "leaderboard hides such runs). Also settable via TRAP_ALLOW_UNANCHORED=1.",
287
+ ),
288
+ ] = False,
235
289
  ) -> None:
236
290
  """Upload a report.json to trapstreet.
237
291
 
@@ -254,9 +308,14 @@ def submit(
254
308
  raise _die(e) from None
255
309
  report_handle = ReportHandle(workspace.resolve(), task_alias, run)
256
310
  try:
257
- report_handle.assert_exists()
311
+ report_data = report_handle.load()
258
312
  except FileNotFoundError:
259
313
  raise _die(f"no report at {report_handle.report_json_path}. Run [bold]tp run[/bold] first.") from None
314
+ # Repeat the unanchored-provenance gate at upload time — the report records
315
+ # what `tp run` saw, so the checkouts aren't re-probed here.
316
+ _confirm_unanchored(
317
+ report_data.provenance, allow=allow_unanchored or _env_truthy("TRAP_ALLOW_UNANCHORED")
318
+ )
260
319
  report_path = report_handle.report_json_path
261
320
 
262
321
  client = ApiClient(resolved_server, resolved_key)
@@ -0,0 +1,86 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import git
6
+
7
+ from trap.git_ops.url import ParsedGitUrl
8
+ from trap.models.provenance import GitProvenance
9
+
10
+
11
+ class LocalRepo:
12
+ """An existing on-disk git checkout — read-only inspection.
13
+
14
+ Distinct from `RemoteRepo` (which clones a declared URL into a root): this wraps
15
+ a `git.Repo` already on disk — whether trap cloned it or the user pointed at
16
+ a local solution. Both clone-sync validation and report provenance go through
17
+ here, so the "open repo + read origin/commit/dirty" logic lives in one place.
18
+ """
19
+
20
+ def __init__(self, repo: git.Repo, path: Path) -> None:
21
+ self.repo = repo
22
+ # The path this checkout was opened at (may sit below the repo root when
23
+ # opened with search_parent).
24
+ self.path = path
25
+
26
+ @classmethod
27
+ def open(cls, path: Path, *, search_parent: bool = False) -> LocalRepo | None:
28
+ """Open the git checkout at `path`, or None if it isn't a git repo."""
29
+ try:
30
+ return cls(git.Repo(path, search_parent_directories=search_parent), path)
31
+ except (git.InvalidGitRepositoryError, git.NoSuchPathError):
32
+ return None
33
+
34
+ @property
35
+ def origin_normalised_url(self) -> str | None:
36
+ """`origin` remote as a canonical https URL, or None if there's no origin."""
37
+ try:
38
+ return ParsedGitUrl.from_full_url(self.repo.remotes.origin.url).normalised_url
39
+ except AttributeError:
40
+ return None
41
+
42
+ def provenance_issue(self) -> str | None:
43
+ """Why this checkout cannot be anchored to {repo, commit}, or None if it can.
44
+
45
+ The run isn't reproducible from remote+commit alone when the repo has no
46
+ origin, no commit, or uncommitted tracked-file changes. Best-effort: a probe
47
+ failure degrades to a reason rather than raising. Untracked files (run
48
+ outputs under .trap/, .venv, …) don't count as dirty.
49
+ """
50
+ try:
51
+ if self.origin_normalised_url is None:
52
+ return "no origin remote"
53
+ if not self.repo.head.is_valid():
54
+ return "no commit to anchor to"
55
+ if self.repo.is_dirty():
56
+ return "uncommitted changes"
57
+ return None
58
+ except Exception:
59
+ return "git probe failed"
60
+
61
+ @property
62
+ def subdirectory(self) -> str | None:
63
+ """Opened path relative to the repo root, None at the root (or in a bare repo)."""
64
+ root = self.repo.working_tree_dir
65
+ if root is None:
66
+ return None
67
+ rel = self.path.resolve().relative_to(Path(root).resolve())
68
+ return rel.as_posix() if rel != Path(".") else None
69
+
70
+ def provenance(self) -> GitProvenance:
71
+ """{repo, commit, subdirectory} for a clean checkout with an origin, else empty
72
+ with `issue` naming why — we claim nothing about a run that isn't reproducible."""
73
+ issue = self.provenance_issue()
74
+ if issue is not None:
75
+ return GitProvenance(issue=issue)
76
+ return GitProvenance(
77
+ repo=self.origin_normalised_url,
78
+ commit=self.repo.head.commit.hexsha,
79
+ subdirectory=self.subdirectory,
80
+ )
81
+
82
+ @classmethod
83
+ def provenance_of(cls, path: Path) -> GitProvenance:
84
+ """Provenance ({repo, commit}) of the checkout at `path`."""
85
+ local_repo = cls.open(path, search_parent=True)
86
+ return local_repo.provenance() if local_repo else GitProvenance(issue="not a git repo")
@@ -4,11 +4,17 @@ from pydantic import BaseModel, Field
4
4
 
5
5
 
6
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)."""
7
+ """Git origin of one checkout: {repo, commit, subdirectory}. repo and commit are
8
+ None when the tree isn't a clean, remote-backed git repo `issue` then names why
9
+ (see LocalRepo.provenance_issue). Anchored checkouts carry no issue.
10
+
11
+ `subdirectory` locates the checkout within the repo (None at the root), so two
12
+ solutions or tasks living in different subdirectories of one repo stay distinct."""
9
13
 
10
14
  repo: str | None = None
11
15
  commit: str | None = None
16
+ subdirectory: str | None = None
17
+ issue: str | None = None
12
18
 
13
19
 
14
20
  class Provenance(BaseModel):
@@ -28,6 +28,9 @@ class ReportData(BaseModel):
28
28
  started_at_utc: str
29
29
  finished_at_utc: str
30
30
 
31
+ # Leaderboard identity the solution author chose in trap.yaml (`name`); None →
32
+ # the server auto-assigns one.
33
+ solution_name: str | None = None
31
34
  # Engine identity (model/framework). Self-reported from trap.yaml today, but the
32
35
  # website consumes it from the report — never from trap.yaml directly — precisely
33
36
  # because the source may change: a future version is expected to derive this by
@@ -56,6 +59,7 @@ class ReportData(BaseModel):
56
59
  grader_metrics=grader_metrics,
57
60
  started_at_utc=started_at_utc.isoformat(timespec="seconds"),
58
61
  finished_at_utc=finished_at_utc.isoformat(timespec="seconds"),
62
+ solution_name=trap_config.name,
59
63
  profile=trap_config.profile,
60
64
  environment=environment,
61
65
  )
@@ -1,60 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from pathlib import Path
4
-
5
- import git
6
-
7
- from trap.git_ops.url import ParsedGitUrl
8
- from trap.models.provenance import GitProvenance
9
-
10
-
11
- class LocalRepo:
12
- """An existing on-disk git checkout — read-only inspection.
13
-
14
- Distinct from `RemoteRepo` (which clones a declared URL into a root): this wraps
15
- a `git.Repo` already on disk — whether trap cloned it or the user pointed at
16
- a local solution. Both clone-sync validation and report provenance go through
17
- here, so the "open repo + read origin/commit/dirty" logic lives in one place.
18
- """
19
-
20
- def __init__(self, repo: git.Repo) -> None:
21
- self.repo = repo
22
-
23
- @classmethod
24
- def open(cls, path: Path, *, search_parent: bool = False) -> LocalRepo | None:
25
- """Open the git checkout at `path`, or None if it isn't a git repo."""
26
- try:
27
- return cls(git.Repo(path, search_parent_directories=search_parent))
28
- except (git.InvalidGitRepositoryError, git.NoSuchPathError):
29
- return None
30
-
31
- @property
32
- def origin_normalised_url(self) -> str | None:
33
- """`origin` remote as a canonical https URL, or None if there's no origin."""
34
- try:
35
- return ParsedGitUrl.from_full_url(self.repo.remotes.origin.url).normalised_url
36
- except AttributeError:
37
- return None
38
-
39
- def provenance(self) -> GitProvenance:
40
- """{repo, commit} for a clean checkout with an origin, else empty.
41
-
42
- Empty for a dirty tree (tracked-file changes), a remote-less repo, or any
43
- git error — the run isn't reproducible from remote+commit alone, so we
44
- claim nothing. Best-effort: a probe failure degrades to empty rather than
45
- aborting an otherwise-complete run. Untracked files (run outputs under
46
- .trap/, .venv, …) don't count as dirty.
47
- """
48
- try:
49
- url = self.origin_normalised_url
50
- if url is None or not self.repo.head.is_valid() or self.repo.is_dirty():
51
- return GitProvenance()
52
- return GitProvenance(repo=url, commit=self.repo.head.commit.hexsha)
53
- except Exception:
54
- return GitProvenance()
55
-
56
- @classmethod
57
- def provenance_of(cls, path: Path) -> GitProvenance:
58
- """Provenance ({repo, commit}) of the checkout at `path`, empty if not a git repo."""
59
- local_repo = cls.open(path, search_parent=True)
60
- return local_repo.provenance() if local_repo else GitProvenance()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes