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.
- {trap_cli-0.0.1 → trap_cli-0.0.3}/PKG-INFO +1 -1
- {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/_version.py +2 -2
- {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/auth/client.py +2 -1
- {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/cli/__init__.py +68 -9
- trap_cli-0.0.3/src/trap/git_ops/local.py +86 -0
- {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/models/provenance.py +8 -2
- {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/models/report.py +4 -0
- trap_cli-0.0.1/src/trap/git_ops/local.py +0 -60
- {trap_cli-0.0.1 → trap_cli-0.0.3}/.gitignore +0 -0
- {trap_cli-0.0.1 → trap_cli-0.0.3}/LICENSE +0 -0
- {trap_cli-0.0.1 → trap_cli-0.0.3}/README.md +0 -0
- {trap_cli-0.0.1 → trap_cli-0.0.3}/examples/echo/task/README.md +0 -0
- {trap_cli-0.0.1 → trap_cli-0.0.3}/pyproject.toml +0 -0
- {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/__init__.py +0 -0
- {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/auth/__init__.py +0 -0
- {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/auth/login.py +0 -0
- {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/auth/oauth.py +0 -0
- {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/auth/store.py +0 -0
- {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/cli/_auth.py +0 -0
- {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/cost/__init__.py +0 -0
- {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/cost/calculator.py +0 -0
- {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/cost/providers.py +0 -0
- {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/cost/proxy.py +0 -0
- {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/display/__init__.py +0 -0
- {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/display/progress.py +0 -0
- {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/display/submit.py +0 -0
- {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/environment/__init__.py +0 -0
- {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/environment/detector.py +0 -0
- {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/git_ops/__init__.py +0 -0
- {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/git_ops/base.py +0 -0
- {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/git_ops/remote.py +0 -0
- {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/git_ops/rev.py +0 -0
- {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/git_ops/url.py +0 -0
- {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/loader/__init__.py +0 -0
- {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/loader/errors.py +0 -0
- {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/loader/trap_yaml.py +0 -0
- {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/loader/traptask_yaml.py +0 -0
- {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/models/__init__.py +0 -0
- {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/models/cost.py +0 -0
- {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/models/environment.py +0 -0
- {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/models/results.py +0 -0
- {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/models/trap_yaml.py +0 -0
- {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/models/traptask_yaml.py +0 -0
- {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/report/__init__.py +0 -0
- {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/report/base.py +0 -0
- {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/report/handle.py +0 -0
- {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/report/json.py +0 -0
- {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/report/rich.py +0 -0
- {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/runner/__init__.py +0 -0
- {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/runner/capture.py +0 -0
- {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/runner/grader.py +0 -0
- {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/runner/judge.py +0 -0
- {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/runner/layout.py +0 -0
- {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/runner/proc.py +0 -0
- {trap_cli-0.0.1 → trap_cli-0.0.3}/src/trap/runner/solution.py +0 -0
- {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.
|
|
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.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 0,
|
|
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
|
|
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
|
-
|
|
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.
|
|
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}.
|
|
8
|
-
clean, remote-backed git repo
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|