trap-cli 0.0.5__tar.gz → 0.0.7__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 (59) hide show
  1. {trap_cli-0.0.5 → trap_cli-0.0.7}/PKG-INFO +1 -1
  2. {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/_version.py +2 -2
  3. {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/auth/client.py +10 -1
  4. {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/cli/__init__.py +43 -22
  5. {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/display/submit.py +7 -4
  6. {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/git_ops/url.py +15 -0
  7. {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/loader/trap_yaml.py +1 -1
  8. {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/loader/traptask_yaml.py +22 -10
  9. {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/report/__init__.py +1 -2
  10. {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/runner/task.py +0 -14
  11. trap_cli-0.0.7/src/trap/workspace/__init__.py +23 -0
  12. trap_cli-0.0.7/src/trap/workspace/identity.py +58 -0
  13. trap_cli-0.0.7/src/trap/workspace/store.py +113 -0
  14. trap_cli-0.0.5/src/trap/report/handle.py +0 -54
  15. {trap_cli-0.0.5 → trap_cli-0.0.7}/.gitignore +0 -0
  16. {trap_cli-0.0.5 → trap_cli-0.0.7}/LICENSE +0 -0
  17. {trap_cli-0.0.5 → trap_cli-0.0.7}/README.md +0 -0
  18. {trap_cli-0.0.5 → trap_cli-0.0.7}/examples/echo/task/README.md +0 -0
  19. {trap_cli-0.0.5 → trap_cli-0.0.7}/pyproject.toml +0 -0
  20. {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/__init__.py +0 -0
  21. {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/auth/__init__.py +0 -0
  22. {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/auth/login.py +0 -0
  23. {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/auth/oauth.py +0 -0
  24. {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/auth/store.py +0 -0
  25. {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/cli/_auth.py +0 -0
  26. {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/cli/_console.py +0 -0
  27. {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/cost/__init__.py +0 -0
  28. {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/cost/calculator.py +0 -0
  29. {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/cost/providers.py +0 -0
  30. {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/cost/proxy.py +0 -0
  31. {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/display/__init__.py +0 -0
  32. {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/display/progress.py +0 -0
  33. {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/environment/__init__.py +0 -0
  34. {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/environment/detector.py +0 -0
  35. {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/git_ops/__init__.py +0 -0
  36. {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/git_ops/base.py +0 -0
  37. {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/git_ops/local.py +0 -0
  38. {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/git_ops/remote.py +0 -0
  39. {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/git_ops/rev.py +0 -0
  40. {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/loader/__init__.py +0 -0
  41. {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/loader/errors.py +0 -0
  42. {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/models/__init__.py +0 -0
  43. {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/models/cost.py +0 -0
  44. {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/models/environment.py +0 -0
  45. {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/models/provenance.py +0 -0
  46. {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/models/report.py +0 -0
  47. {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/models/results.py +0 -0
  48. {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/models/trap_yaml.py +0 -0
  49. {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/models/traptask_yaml.py +0 -0
  50. {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/report/base.py +0 -0
  51. {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/report/json.py +0 -0
  52. {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/report/rich.py +0 -0
  53. {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/runner/__init__.py +0 -0
  54. {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/runner/capture.py +0 -0
  55. {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/runner/grader.py +0 -0
  56. {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/runner/judge.py +0 -0
  57. {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/runner/layout.py +0 -0
  58. {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/runner/proc.py +0 -0
  59. {trap_cli-0.0.5 → trap_cli-0.0.7}/src/trap/runner/solution.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: trap-cli
3
- Version: 0.0.5
3
+ Version: 0.0.7
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.5'
22
- __version_tuple__ = version_tuple = (0, 0, 5)
21
+ __version__ = version = '0.0.7'
22
+ __version_tuple__ = version_tuple = (0, 0, 7)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -52,6 +52,15 @@ class ApiClient:
52
52
  resp.raise_for_status()
53
53
  return resp.json()
54
54
  except httpx.HTTPStatusError as e:
55
- raise ApiError(f"http {e.response.status_code}: {e.response.text}") from None
55
+ raise ApiError(f"http {e.response.status_code}: {_error_detail(e.response)}") from None
56
56
  except httpx.RequestError as e:
57
57
  raise ApiError(f"connection error: {e}") from None
58
+
59
+
60
+ def _error_detail(resp: httpx.Response) -> str:
61
+ """The server's JSON {error} message when present, else the raw body text."""
62
+ try:
63
+ error = resp.json().get("error")
64
+ except (ValueError, AttributeError):
65
+ return resp.text
66
+ return str(error) if error else resp.text
@@ -17,9 +17,10 @@ from trap.display import CaseProgress, render_submit_result
17
17
  from trap.environment import EnvironmentDetector
18
18
  from trap.git_ops import GitOpsError, LocalRepo, ParsedGitUrl
19
19
  from trap.loader import ConfigError, TrapLoader, TraptaskLoader
20
- from trap.models import Provenance
21
- from trap.report import OutputFormat, ReportHandle, renderer_factory
20
+ from trap.models import Provenance, ReportData
21
+ from trap.report import OutputFormat, renderer_factory
22
22
  from trap.runner import TaskRunner
23
+ from trap.workspace import SolutionIdentity, Workspace
23
24
 
24
25
  app = typer.Typer(help="AI prompt / agent / workflow / testing framework.")
25
26
  app.add_typer(auth_app, name="auth")
@@ -46,6 +47,22 @@ def _main(
46
47
  """AI prompt / agent / workflow / testing framework."""
47
48
 
48
49
 
50
+ def _no_report(workspace: Workspace, error: FileNotFoundError) -> typer.Exit:
51
+ """Exit for a report miss, with advice matching the likely cause: an empty
52
+ workspace means the user never ran; existing runs under *other* solution keys
53
+ mean a cwd / --solution mismatch, where re-running would just miss again."""
54
+ msg = str(error)
55
+ if keys := workspace.solution_keys():
56
+ msg += (
57
+ "\nsolutions with runs in this workspace: "
58
+ + ", ".join(keys)
59
+ + "\ncheck that --solution (and the cwd) match the ones used for [bold]tp run[/bold]"
60
+ )
61
+ else:
62
+ msg += ". Run [bold]tp run[/bold] first."
63
+ return _die(msg)
64
+
65
+
49
66
  def _confirm_remote(url: str, *, trust: bool) -> None:
50
67
  """Gate trap's auto-download-and-run of a remote (git+ URL) source — the one place
51
68
  trap fetches and executes code the user may not have seen. Returns to proceed;
@@ -145,7 +162,7 @@ def run(
145
162
  help="Force-run the task's setup_cmd even when no remote pull brought new code.",
146
163
  ),
147
164
  ] = False,
148
- workspace: Annotated[Path, typer.Option("--workspace", "-w")] = Path(".trap"),
165
+ workspace: Annotated[Path, typer.Option("--workspace", "-w")] = Path(Workspace.DEFAULT_DIRNAME),
149
166
  environment: Annotated[
150
167
  bool,
151
168
  typer.Option(
@@ -184,8 +201,8 @@ def run(
184
201
  task_binding = trap_yaml_loader.resolve_task(task)
185
202
  if ParsedGitUrl.looks_remote(task_binding.source):
186
203
  _confirm_remote(task_binding.source, trust=trust)
187
- traptask_yaml_loader = TraptaskLoader.from_task(
188
- task_binding, trap_yaml_loader.trap_dir, setup=setup_task
204
+ traptask_yaml_loader = TraptaskLoader.from_task_binding(
205
+ task_binding, trap_yaml_loader.trap_dir, setup=setup_task, workspace_root=workspace.resolve()
189
206
  )
190
207
  except (GitOpsError, ConfigError, subprocess.CalledProcessError) as e:
191
208
  raise _die(e) from None
@@ -204,14 +221,14 @@ def run(
204
221
 
205
222
  started_at_local = datetime.now()
206
223
  ts = started_at_local.isoformat(timespec="seconds")
207
- report_handle = ReportHandle(workspace.resolve(), task_binding.alias, ts)
224
+ ws = Workspace(workspace.resolve(), SolutionIdentity.from_spec(solution).dirname, task_binding.alias)
208
225
 
209
226
  runner = TaskRunner(
210
227
  trap_config=trap_yaml_loader.config,
211
228
  trap_dir=trap_yaml_loader.trap_dir,
212
229
  traptask_config=traptask_yaml_loader.traptask,
213
230
  traptask_dir=traptask_yaml_loader.traptask_dir,
214
- run_dir=report_handle.run_dir,
231
+ run_dir=ws.run_dir(ts),
215
232
  cost_enabled=cost,
216
233
  )
217
234
  prog_console = console if output == OutputFormat.rich else None
@@ -233,8 +250,8 @@ def run(
233
250
  except Exception:
234
251
  environment_info = None
235
252
 
236
- report_data = report_handle.save(
237
- case_results=case_results,
253
+ report_data = ReportData.from_run(
254
+ cases_results=case_results,
238
255
  trap_config=trap_yaml_loader.config,
239
256
  started_at_utc=started_at_local.astimezone(UTC),
240
257
  finished_at_utc=finished_at_utc,
@@ -242,7 +259,11 @@ def run(
242
259
  provenance=provenance,
243
260
  environment=environment_info,
244
261
  )
262
+ ws.save_as_report(ts, report_data)
245
263
  renderer_factory(output).render(report_data)
264
+ if output == OutputFormat.rich:
265
+ # Where the artifacts landed — the run id doubles as the `--run` handle.
266
+ console.print(f"[dim]run {ts} → {ws.report_json_path(ts)}[/dim]")
246
267
  # trap reports facts, not a verdict: a completed run exits 0 regardless of per-case
247
268
  # exit codes or scores (read the grader / report.json to gate CI). trap-level
248
269
  # failures (bad config, git errors) still exit non-zero via _die.
@@ -257,18 +278,18 @@ def report(
257
278
  typer.Option("--solution", help="Local solution path holding trap.yaml (default: cwd)."),
258
279
  ] = None,
259
280
  output: Annotated[OutputFormat, typer.Option("--output", "-o")] = OutputFormat.rich,
260
- workspace: Annotated[Path, typer.Option("--workspace", "-w")] = Path(".trap"),
281
+ workspace: Annotated[Path, typer.Option("--workspace", "-w")] = Path(Workspace.DEFAULT_DIRNAME),
261
282
  ) -> None:
262
283
  """Display a report for a task (defaults to latest run)."""
263
284
  try:
264
285
  task_alias = TrapLoader.from_solution(solution).resolve_task(task).alias
265
286
  except (GitOpsError, ConfigError) as e:
266
287
  raise _die(e) from None
267
- handle = ReportHandle(workspace.resolve(), task_alias, run)
288
+ ws = Workspace(workspace.resolve(), SolutionIdentity.from_spec(solution).dirname, task_alias)
268
289
  try:
269
- report_data = handle.load()
270
- except FileNotFoundError:
271
- raise _die(f"no report at {handle.report_json_path}; run `tp run` first") from None
290
+ report_data = ws.load(run)
291
+ except FileNotFoundError as e:
292
+ raise _no_report(ws, e) from None
272
293
  renderer_factory(output).render(report_data)
273
294
 
274
295
 
@@ -285,7 +306,7 @@ def submit(
285
306
  str | None,
286
307
  typer.Option("--solution", help="Local solution path holding trap.yaml (default: cwd)."),
287
308
  ] = None,
288
- workspace: Annotated[Path, typer.Option("--workspace", "-w")] = Path(".trap"),
309
+ workspace: Annotated[Path, typer.Option("--workspace", "-w")] = Path(Workspace.DEFAULT_DIRNAME),
289
310
  run: Annotated[str, typer.Option("--run", "-r", help="Which run to upload.")] = "latest",
290
311
  allow_unanchored: Annotated[
291
312
  bool,
@@ -298,8 +319,8 @@ def submit(
298
319
  ) -> None:
299
320
  """Upload a report.json to trapstreet.
300
321
 
301
- Reads from the .trap/<task>/<run>/report.json workspace that `tp run`
302
- populated.
322
+ Reads from the .trap/runs/<solution>/<task>/<run>/report.json workspace
323
+ that `tp run` populated.
303
324
  """
304
325
  stored = AuthStore().load()
305
326
  # priority: TRAPSTREET_URL env > stored > default
@@ -315,17 +336,17 @@ def submit(
315
336
  task_alias = TrapLoader.from_solution(solution).resolve_task(task).alias
316
337
  except (GitOpsError, ConfigError) as e:
317
338
  raise _die(e) from None
318
- report_handle = ReportHandle(workspace.resolve(), task_alias, run)
339
+ ws = Workspace(workspace.resolve(), SolutionIdentity.from_spec(solution).dirname, task_alias)
319
340
  try:
320
- report_data = report_handle.load()
321
- except FileNotFoundError:
322
- raise _die(f"no report at {report_handle.report_json_path}. Run [bold]tp run[/bold] first.") from None
341
+ report_data = ws.load(run)
342
+ except FileNotFoundError as e:
343
+ raise _no_report(ws, e) from None
323
344
  # Repeat the unanchored-provenance gate at upload time — the report records
324
345
  # what `tp run` saw, so the checkouts aren't re-probed here.
325
346
  _confirm_unanchored(
326
347
  report_data.provenance, allow=allow_unanchored or _env_truthy("TRAP_ALLOW_UNANCHORED")
327
348
  )
328
- report_path = report_handle.report_json_path
349
+ report_path = ws.report_json_path(run)
329
350
 
330
351
  client = ApiClient(resolved_server, resolved_key)
331
352
  try:
@@ -7,14 +7,17 @@ console = Console()
7
7
 
8
8
 
9
9
  def render_submit_result(resp_data: dict) -> None:
10
+ """Render a successful POST /api/submit response: {run: {id}, view_url}.
11
+
12
+ Success is decided by the HTTP status alone (ApiClient.submit raises on
13
+ non-2xx), so this only confirms the upload and points at the run page —
14
+ scores and case detail live there, not in the response."""
10
15
  run_obj = resp_data.get("run") or {}
11
- passed = run_obj.get("passed")
16
+ console.print("[green]✓ submitted[/green]")
12
17
  table = Table.grid(padding=(0, 2))
13
18
  table.add_column(style="dim")
14
19
  table.add_column()
15
- table.add_row("status", "[green] passed[/green]" if passed else "[red]✗ failed[/red]")
16
- table.add_row("run", f"[bold]{run_obj.get('id', '?')}[/bold]")
17
- table.add_row("score", f"[cyan]{run_obj.get('total_score')}[/cyan]")
20
+ table.add_row("run_id", f"[bold]{run_obj.get('id', '?')}[/bold]")
18
21
  if view_url := resp_data.get("view_url"):
19
22
  table.add_row("url", f"[link={view_url}]{view_url}[/link]")
20
23
  console.print(table)
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import re
4
4
  from dataclasses import dataclass
5
+ from pathlib import PurePosixPath
5
6
 
6
7
  from trap.git_ops.base import GitOpsError
7
8
 
@@ -96,3 +97,17 @@ class ParsedGitUrl:
96
97
  if m:
97
98
  return f"https://{m.group(1)}/{m.group(2)}"
98
99
  return re.sub(r"\.git$", "", self.repo) # https/http: drop trailing .git
100
+
101
+ @property
102
+ def dir_basename(self) -> str:
103
+ """Basename of the directory the URL addresses: the subdirectory when one
104
+ is set, else the repo."""
105
+ return PurePosixPath(self.subdirectory).name if self.subdirectory else self.basename
106
+
107
+ @property
108
+ def normalised_dir_url(self) -> str:
109
+ """Canonical, rev-independent address of the directory the URL points at:
110
+ ``normalised_url`` plus ``#<subdirectory>`` when one is set. The rev is
111
+ excluded on purpose — it can float without changing *which* directory
112
+ this is."""
113
+ return self.normalised_url + (f"#{self.subdirectory}" if self.subdirectory else "")
@@ -68,7 +68,7 @@ class TrapLoader:
68
68
  The solution's `setup_cmd` (in its trap.yaml, so it travels with the
69
69
  solution) prepares the checkout. It auto-runs when a remote pull brought
70
70
  new code, and otherwise only when `setup` is set (`tp run --setup-solution`).
71
- Mirrors `TraptaskLoader.from_task`.
71
+ Mirrors `TraptaskLoader.from_task_binding`.
72
72
  """
73
73
  from trap.git_ops import GitOpsError, ParsedGitUrl, RemoteRepo
74
74
 
@@ -10,6 +10,7 @@ from pydantic import ValidationError
10
10
 
11
11
  from trap.loader.errors import ConfigError
12
12
  from trap.models import TaskBinding, TraptaskCase, TraptaskConfig
13
+ from trap.workspace import Workspace
13
14
 
14
15
 
15
16
  class TraptaskLoader:
@@ -41,13 +42,21 @@ class TraptaskLoader:
41
42
  return TraptaskConfig(cases=tuple(TraptaskCase(id=case_id) for case_id in case_ids))
42
43
 
43
44
  @classmethod
44
- def from_task(cls, task: TaskBinding, trap_dir: Path, setup: bool = False) -> TraptaskLoader:
45
+ def from_task_binding(
46
+ cls,
47
+ task_binding: TaskBinding,
48
+ trap_dir: Path,
49
+ setup: bool = False,
50
+ workspace_root: Path = Path(Workspace.DEFAULT_DIRNAME),
51
+ ) -> TraptaskLoader:
45
52
  """Resolve traptask.yaml from a TaskBinding's source and the trap.yaml directory.
46
53
 
47
54
  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).
55
+ A URL clones into `clone_to` (resolved against `trap_dir`, since it is the
56
+ solution author's config) or, when omitted, the workspace's hidden
57
+ `<workspace_root>/repos/<repo>` cache the same root that holds run
58
+ artifacts; a local path uses it in place and rejects `clone_to`.
59
+ Raises GitOpsError on a bad spec (caller maps it to a CLI error).
51
60
 
52
61
  The task's `setup_cmd` (declared in its traptask.yaml, so it travels with the
53
62
  task version) prepares the checkout. It auto-runs when a remote pull brought
@@ -56,17 +65,20 @@ class TraptaskLoader:
56
65
  """
57
66
  from trap.git_ops import GitOpsError, ParsedGitUrl, RemoteRepo
58
67
 
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())
68
+ if ParsedGitUrl.looks_remote(task_binding.source):
69
+ parsed = ParsedGitUrl.from_full_url(task_binding.source)
70
+ if task_binding.clone_to is not None:
71
+ dest = trap_dir / task_binding.clone_to
72
+ else:
73
+ dest = Workspace.clone_cache_dir(workspace_root, parsed.basename)
74
+ remote_repo = RemoteRepo(parsed, dest.resolve())
63
75
  is_local_changed = remote_repo.ensure()
64
76
  traptask_dir = remote_repo.local_dir
65
77
  else:
66
- if task.clone_to is not None:
78
+ if task_binding.clone_to is not None:
67
79
  raise GitOpsError("clone_to only applies to a remote (git URL) source")
68
80
  is_local_changed = False
69
- traptask_dir = (trap_dir / task.source).resolve()
81
+ traptask_dir = (trap_dir / task_binding.source).resolve()
70
82
  loader = cls(traptask_dir / "traptask.yaml")
71
83
  if (is_local_changed or setup) and loader.traptask.setup_cmd:
72
84
  # raises subprocess.CalledProcessError on non-zero exit
@@ -3,11 +3,10 @@ from __future__ import annotations
3
3
  import enum
4
4
 
5
5
  from trap.report.base import BaseRenderer
6
- from trap.report.handle import ReportHandle
7
6
  from trap.report.json import JsonRenderer
8
7
  from trap.report.rich import RichRenderer
9
8
 
10
- __all__ = ["BaseRenderer", "JsonRenderer", "OutputFormat", "ReportHandle", "RichRenderer", "renderer_factory"]
9
+ __all__ = ["BaseRenderer", "JsonRenderer", "OutputFormat", "RichRenderer", "renderer_factory"]
11
10
 
12
11
 
13
12
  class OutputFormat(enum.StrEnum):
@@ -1,7 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from collections.abc import Callable, Iterable, Iterator
4
- from datetime import datetime
5
4
  from functools import cached_property
6
5
  from pathlib import Path
7
6
  from typing import Any
@@ -40,18 +39,6 @@ class TaskRunner:
40
39
  """The task's expected/ dir (traptask_dir / dirs.expected), resolved once on first use."""
41
40
  return (self.traptask_dir / self.traptask_config.dirs.expected).resolve()
42
41
 
43
- def _update_latest(self) -> None:
44
- latest = self.run_dir.parent / "latest"
45
- if latest.is_symlink():
46
- latest.unlink()
47
- elif latest.exists():
48
- # Path exists but isn't a symlink — likely an interrupted prior run
49
- # or a sync tool that materialized the symlink as a real directory.
50
- # Move it aside (non-destructive) so subsequent runs self-heal.
51
- suffix = datetime.now().isoformat(timespec="microseconds")
52
- latest.rename(latest.with_name(f"latest.broken.{suffix}"))
53
- latest.symlink_to(self.run_dir.name)
54
-
55
42
  def _iter_cases(
56
43
  self,
57
44
  cases: Iterable[TraptaskCase],
@@ -98,5 +85,4 @@ class TaskRunner:
98
85
  # still completes and the report still saves).
99
86
  grader_metrics = GraderRunner(self, case_results).run()
100
87
 
101
- self._update_latest()
102
88
  return case_results, grader_metrics
@@ -0,0 +1,23 @@
1
+ """Workspace addressing: where a solution's runs live inside `.trap/`.
2
+
3
+ Layout: ``<workspace>/runs/<solution-key>/<task_alias>/<run>/``.
4
+
5
+ Two objects own this package's concerns: :class:`SolutionIdentity`
6
+ (``identity.py``) derives which solution a run belongs to and projects it to a
7
+ directory name (its ``dirname`` is the solution key), and :class:`Workspace`
8
+ (``store.py``) answers queries and does report IO against one `.trap/` store,
9
+ scoped to one (solution, task) pair fixed at construction. The key
10
+ carries the
11
+ run's *identity* inside the store, so ``latest`` is scoped per (solution, task)
12
+ by construction — a cwd mismatch between ``tp run`` and ``tp submit`` degrades
13
+ to a loud "not found" instead of silently reading another solution's report.
14
+ Runs live under the ``runs/`` namespace so run storage never collides with
15
+ sibling namespaces like ``repos/`` (the clone cache).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from trap.workspace.identity import SolutionIdentity
21
+ from trap.workspace.store import Workspace
22
+
23
+ __all__ = ["SolutionIdentity", "Workspace"]
@@ -0,0 +1,58 @@
1
+ """SolutionIdentity — the identity a solution's runs are stored under."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import re
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+ from trap.git_ops import ParsedGitUrl
11
+
12
+ _UNSAFE = re.compile(r"[^A-Za-z0-9._-]+")
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class SolutionIdentity:
17
+ """The identity a solution's runs are stored under: ``<readable>-<hash8>``.
18
+
19
+ ``readable`` is the solution's basename (never encodes path separators,
20
+ never leaks ``..``); ``ident`` is the full identity the hash disambiguates
21
+ same-named solutions by — the resolved absolute path, or the normalised
22
+ URL + subdirectory for a remote. Aliases of one solution (``./x``, ``x``,
23
+ an absolute path, a symlinked path) all derive the same key."""
24
+
25
+ readable: str
26
+ ident: str
27
+
28
+ @classmethod
29
+ def from_spec(cls, solution: str | None) -> SolutionIdentity:
30
+ """Derive the key from a ``--solution`` spec (local path or git+ URL)."""
31
+ if solution is not None and ParsedGitUrl.looks_remote(solution):
32
+ return cls._from_remote(ParsedGitUrl.from_full_url(solution))
33
+ return cls._from_local(solution)
34
+
35
+ @classmethod
36
+ def _from_remote(cls, parsed: ParsedGitUrl) -> SolutionIdentity:
37
+ # The URL is the stable identity — the clone dir can be moved (--clone-to)
38
+ # without changing which solution this is.
39
+ return cls(readable=parsed.dir_basename, ident=parsed.normalised_dir_url)
40
+
41
+ @classmethod
42
+ def _from_local(cls, solution: str | None) -> SolutionIdentity:
43
+ resolved = (Path.cwd() / (solution or ".")).resolve()
44
+ return cls(readable=resolved.name, ident=str(resolved))
45
+
46
+ @property
47
+ def dirname(self) -> str:
48
+ """The key as it appears on disk: the directory name under ``runs/``."""
49
+ return f"{self._safe_readable}-{self._digest}"
50
+
51
+ @property
52
+ def _safe_readable(self) -> str:
53
+ """`readable` reduced to dirname-safe characters, with a non-empty fallback."""
54
+ return _UNSAFE.sub("-", self.readable).strip("-.") or "solution"
55
+
56
+ @property
57
+ def _digest(self) -> str:
58
+ return hashlib.sha256(self.ident.encode()).hexdigest()[:8]
@@ -0,0 +1,113 @@
1
+ """Workspace — one `.trap/` store and the queries it answers.
2
+
3
+ ``latest`` is a *derived* value — the newest timestamp-named run directory that
4
+ holds a report, detected at every use. Nothing on disk records which run is
5
+ latest, so there is no pointer to go stale.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from datetime import datetime
11
+ from pathlib import Path
12
+
13
+ from trap.models import ReportData
14
+
15
+
16
+ class Workspace:
17
+ """One `.trap/` store, scoped to one (solution, task) pair.
18
+
19
+ A CLI invocation resolves its solution key and task alias once, so both are
20
+ fixed at construction time — they address one run *series*. Which run inside
21
+ the series to touch is a per-call choice, so ``run`` stays a parameter."""
22
+
23
+ DEFAULT_DIRNAME = ".trap"
24
+ RUNS_DIR = "runs"
25
+ REPOS_DIR = "repos"
26
+ REPORT_FILENAME = "report.json"
27
+
28
+ def __init__(self, root: Path, solution_key: str, task_alias: str) -> None:
29
+ self.root = root
30
+ self.solution_key = solution_key
31
+ self.task_alias = task_alias
32
+
33
+ @classmethod
34
+ def clone_cache_dir(cls, root: Path, repo_basename: str) -> Path:
35
+ """Where a remote task clones when no ``clone_to`` is given: the hidden
36
+ ``repos/`` cache inside the workspace at ``root`` — the same root that
37
+ holds ``runs/``, so the whole store lives in one place."""
38
+ return root / cls.REPOS_DIR / repo_basename
39
+
40
+ @property
41
+ def runs_root(self) -> Path:
42
+ return self.root / self.RUNS_DIR
43
+
44
+ @property
45
+ def solution_dir(self) -> Path:
46
+ """This solution's namespace inside the store."""
47
+ return self.runs_root / self.solution_key
48
+
49
+ @property
50
+ def solution_task_alias_dir(self) -> Path:
51
+ """Where this (solution, task) pair's runs live."""
52
+ return self.solution_dir / self.task_alias
53
+
54
+ def latest_run(self) -> str | None:
55
+ """The newest completed run for this (solution, task), or None when there is none.
56
+
57
+ Derived from the directory names at every call — stray symlinks and
58
+ non-run entries are ignored."""
59
+ task_dir = self.solution_task_alias_dir
60
+ if not task_dir.is_dir():
61
+ return None
62
+ runs = (path.name for path in task_dir.iterdir() if self._is_completed_run(path))
63
+ return max(runs, default=None)
64
+
65
+ def resolved_run(self, run: str) -> str:
66
+ """``"latest"`` resolves to the newest completed run; anything else names
67
+ a run directly (whether its report exists is ``assert_exists``'s concern).
68
+ Raises FileNotFoundError when there is no completed run to resolve
69
+ ``"latest"`` to."""
70
+ if run != "latest":
71
+ return run
72
+ latest = self.latest_run()
73
+ if latest is None:
74
+ raise FileNotFoundError(f"no completed runs in {self.solution_task_alias_dir}")
75
+ return latest
76
+
77
+ def run_dir(self, run: str) -> Path:
78
+ """One run's directory; ``run="latest"`` is resolved at every use."""
79
+ return self.solution_task_alias_dir / self.resolved_run(run)
80
+
81
+ def report_json_path(self, run: str) -> Path:
82
+ return self.run_dir(run) / self.REPORT_FILENAME
83
+
84
+ @classmethod
85
+ def _is_completed_run(cls, path: Path) -> bool:
86
+ """A run counts when its directory is named by an ISO timestamp and holds
87
+ a report — half-written directories from a crashed run never win."""
88
+ if path.is_symlink() or not path.is_dir():
89
+ return False
90
+ try:
91
+ datetime.fromisoformat(path.name)
92
+ except ValueError:
93
+ return False
94
+ return (path / cls.REPORT_FILENAME).is_file()
95
+
96
+ def solution_keys(self) -> list[str]:
97
+ """Solution keys with runs in this workspace — the self-help list for a miss."""
98
+ if not self.runs_root.is_dir():
99
+ return []
100
+ return sorted(path.name for path in self.runs_root.iterdir() if path.is_dir())
101
+
102
+ def save_as_report(self, run: str, data: ReportData) -> None:
103
+ """Write ``run``'s report; ``run`` must name a concrete run, never ``"latest"``."""
104
+ self.run_dir(run).mkdir(parents=True, exist_ok=True)
105
+ self.report_json_path(run).write_text(data.model_dump_json(indent=2))
106
+
107
+ def assert_exists(self, run: str) -> None:
108
+ if not self.report_json_path(run).exists():
109
+ raise FileNotFoundError(f"no report found in {self.run_dir(run)}")
110
+
111
+ def load(self, run: str) -> ReportData:
112
+ self.assert_exists(run)
113
+ return ReportData.model_validate_json(self.report_json_path(run).read_text())
@@ -1,54 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from datetime import datetime
4
- from pathlib import Path
5
- from typing import Any
6
-
7
- from trap.models import CaseResult, Environment, Provenance, ReportData, TrapConfig
8
-
9
- _REPORT_FILENAME = "report.json"
10
-
11
-
12
- class ReportHandle:
13
- def __init__(self, workspace: Path, task_alias: str, run: str) -> None:
14
- self._workspace = workspace
15
- self._task_alias = task_alias
16
- self._run = run
17
-
18
- @property
19
- def run_dir(self) -> Path:
20
- return self._workspace / self._task_alias / self._run
21
-
22
- @property
23
- def report_json_path(self) -> Path:
24
- return self.run_dir / _REPORT_FILENAME
25
-
26
- def save(
27
- self,
28
- trap_config: TrapConfig,
29
- case_results: tuple[CaseResult, ...],
30
- started_at_utc: datetime,
31
- finished_at_utc: datetime,
32
- provenance: Provenance,
33
- grader_metrics: Any = None,
34
- environment: Environment | None = None,
35
- ) -> ReportData:
36
- data = ReportData.from_run(
37
- trap_config=trap_config,
38
- provenance=provenance,
39
- cases_results=case_results,
40
- started_at_utc=started_at_utc,
41
- finished_at_utc=finished_at_utc,
42
- grader_metrics=grader_metrics,
43
- environment=environment,
44
- )
45
- self.report_json_path.write_text(data.model_dump_json(indent=2))
46
- return data
47
-
48
- def assert_exists(self) -> None:
49
- if not self.report_json_path.exists():
50
- raise FileNotFoundError(f"no report found in {self.run_dir}")
51
-
52
- def load(self) -> ReportData:
53
- self.assert_exists()
54
- return ReportData.model_validate_json(self.report_json_path.read_text())
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes