trap-cli 0.0.6__tar.gz → 0.0.8__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 (60) hide show
  1. {trap_cli-0.0.6 → trap_cli-0.0.8}/PKG-INFO +1 -1
  2. {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/_version.py +2 -2
  3. {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/cli/__init__.py +66 -39
  4. trap_cli-0.0.8/src/trap/display/__init__.py +19 -0
  5. trap_cli-0.0.6/src/trap/report/rich.py → trap_cli-0.0.8/src/trap/display/report.py +23 -1
  6. {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/git_ops/url.py +15 -0
  7. {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/loader/trap_yaml.py +1 -1
  8. {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/loader/traptask_yaml.py +22 -10
  9. {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/runner/task.py +0 -14
  10. trap_cli-0.0.8/src/trap/workspace/__init__.py +23 -0
  11. trap_cli-0.0.8/src/trap/workspace/identity.py +58 -0
  12. trap_cli-0.0.8/src/trap/workspace/store.py +113 -0
  13. trap_cli-0.0.6/src/trap/display/__init__.py +0 -4
  14. trap_cli-0.0.6/src/trap/report/__init__.py +0 -19
  15. trap_cli-0.0.6/src/trap/report/base.py +0 -8
  16. trap_cli-0.0.6/src/trap/report/handle.py +0 -54
  17. trap_cli-0.0.6/src/trap/report/json.py +0 -11
  18. {trap_cli-0.0.6 → trap_cli-0.0.8}/.gitignore +0 -0
  19. {trap_cli-0.0.6 → trap_cli-0.0.8}/LICENSE +0 -0
  20. {trap_cli-0.0.6 → trap_cli-0.0.8}/README.md +0 -0
  21. {trap_cli-0.0.6 → trap_cli-0.0.8}/examples/echo/task/README.md +0 -0
  22. {trap_cli-0.0.6 → trap_cli-0.0.8}/pyproject.toml +0 -0
  23. {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/__init__.py +0 -0
  24. {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/auth/__init__.py +0 -0
  25. {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/auth/client.py +0 -0
  26. {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/auth/login.py +0 -0
  27. {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/auth/oauth.py +0 -0
  28. {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/auth/store.py +0 -0
  29. {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/cli/_auth.py +0 -0
  30. {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/cli/_console.py +0 -0
  31. {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/cost/__init__.py +0 -0
  32. {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/cost/calculator.py +0 -0
  33. {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/cost/providers.py +0 -0
  34. {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/cost/proxy.py +0 -0
  35. {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/display/progress.py +0 -0
  36. {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/display/submit.py +0 -0
  37. {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/environment/__init__.py +0 -0
  38. {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/environment/detector.py +0 -0
  39. {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/git_ops/__init__.py +0 -0
  40. {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/git_ops/base.py +0 -0
  41. {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/git_ops/local.py +0 -0
  42. {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/git_ops/remote.py +0 -0
  43. {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/git_ops/rev.py +0 -0
  44. {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/loader/__init__.py +0 -0
  45. {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/loader/errors.py +0 -0
  46. {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/models/__init__.py +0 -0
  47. {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/models/cost.py +0 -0
  48. {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/models/environment.py +0 -0
  49. {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/models/provenance.py +0 -0
  50. {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/models/report.py +0 -0
  51. {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/models/results.py +0 -0
  52. {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/models/trap_yaml.py +0 -0
  53. {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/models/traptask_yaml.py +0 -0
  54. {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/runner/__init__.py +0 -0
  55. {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/runner/capture.py +0 -0
  56. {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/runner/grader.py +0 -0
  57. {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/runner/judge.py +0 -0
  58. {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/runner/layout.py +0 -0
  59. {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/runner/proc.py +0 -0
  60. {trap_cli-0.0.6 → trap_cli-0.0.8}/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.6
3
+ Version: 0.0.8
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.6'
22
- __version_tuple__ = version_tuple = (0, 0, 6)
21
+ __version__ = version = '0.0.8'
22
+ __version_tuple__ = version_tuple = (0, 0, 8)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -13,13 +13,13 @@ from trap import __version__
13
13
  from trap.auth import DEFAULT_SERVER, ApiClient, ApiError, AuthStore
14
14
  from trap.cli._auth import auth_app
15
15
  from trap.cli._console import _die, _env_truthy, console, err_console
16
- from trap.display import CaseProgress, render_submit_result
16
+ from trap.display import CaseProgress, OutputFormat, render_submit_result, renderer_factory
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
22
21
  from trap.runner import TaskRunner
22
+ from trap.workspace import SolutionIdentity, Workspace
23
23
 
24
24
  app = typer.Typer(help="AI prompt / agent / workflow / testing framework.")
25
25
  app.add_typer(auth_app, name="auth")
@@ -46,6 +46,22 @@ def _main(
46
46
  """AI prompt / agent / workflow / testing framework."""
47
47
 
48
48
 
49
+ def _no_report(workspace: Workspace, error: FileNotFoundError) -> typer.Exit:
50
+ """Exit for a report miss, with advice matching the likely cause: an empty
51
+ workspace means the user never ran; existing runs under *other* solution keys
52
+ mean a cwd / SOLUTION mismatch, where re-running would just miss again."""
53
+ msg = str(error)
54
+ if keys := workspace.solution_keys():
55
+ msg += (
56
+ "\nsolutions with runs in this workspace: "
57
+ + ", ".join(keys)
58
+ + "\ncheck that SOLUTION (and the cwd) match the ones used for [bold]tp run[/bold]"
59
+ )
60
+ else:
61
+ msg += ". Run [bold]tp run[/bold] first."
62
+ return _die(msg)
63
+
64
+
49
65
  def _confirm_remote(url: str, *, trust: bool) -> None:
50
66
  """Gate trap's auto-download-and-run of a remote (git+ URL) source — the one place
51
67
  trap fetches and executes code the user may not have seen. Returns to proceed;
@@ -103,14 +119,19 @@ def _confirm_unanchored(provenance: Provenance, *, allow: bool) -> None:
103
119
 
104
120
  @app.command()
105
121
  def run(
106
- task: Annotated[str | None, typer.Argument()] = None,
107
122
  solution: Annotated[
108
123
  str | None,
109
- typer.Option("--solution", help="Solution to run: a local path or a git+ URL (default: cwd)."),
124
+ typer.Argument(help="Solution to run: a local path or a git+ URL (default: cwd)."),
110
125
  ] = None,
126
+ task: Annotated[
127
+ str | None,
128
+ typer.Option("--task", help="Task alias from trap.yaml (default: the first task)."),
129
+ ] = None,
130
+ workspace: Annotated[Path, typer.Option("--workspace", "-w")] = Path(Workspace.DEFAULT_DIRNAME),
131
+ output: Annotated[OutputFormat, typer.Option("--output", "-o")] = OutputFormat.rich,
111
132
  clone_to: Annotated[
112
133
  Path | None,
113
- typer.Option("--clone-to", help="Where to clone a git+ URL --solution (default: ./<repo>)."),
134
+ typer.Option("--clone-to", help="Where to clone a git+ URL SOLUTION (default: ./<repo>)."),
114
135
  ] = None,
115
136
  trust_remote: Annotated[
116
137
  bool,
@@ -129,7 +150,6 @@ def run(
129
150
  ),
130
151
  ] = False,
131
152
  tags: Annotated[list[str] | None, typer.Option("--tag", "-t")] = None,
132
- output: Annotated[OutputFormat, typer.Option("--output", "-o")] = OutputFormat.rich,
133
153
  fail_fast: Annotated[bool, typer.Option("--fail-fast")] = False,
134
154
  setup_solution: Annotated[
135
155
  bool,
@@ -145,7 +165,6 @@ def run(
145
165
  help="Force-run the task's setup_cmd even when no remote pull brought new code.",
146
166
  ),
147
167
  ] = False,
148
- workspace: Annotated[Path, typer.Option("--workspace", "-w")] = Path(".trap"),
149
168
  environment: Annotated[
150
169
  bool,
151
170
  typer.Option(
@@ -163,11 +182,11 @@ def run(
163
182
  ) -> None:
164
183
  """Run a task against a solution.
165
184
 
166
- --solution is the solution to run: a local path, or a git+ URL to clone
167
- into ./<repo> (or --clone-to). Omit it to use the trap.yaml in the cwd.
185
+ SOLUTION is a local path, or a git+ URL to clone into ./<repo> (or
186
+ --clone-to). Omit it to use the trap.yaml in the cwd.
168
187
  """
169
188
  # Gate trap's auto-download-and-run of any remote source before it happens — once
170
- # for a remote --solution, once for a remote task source (resolved from trap.yaml).
189
+ # for a remote SOLUTION, once for a remote task source (resolved from trap.yaml).
171
190
  trust = trust_remote or _env_truthy("TRAP_TRUST_REMOTE")
172
191
  if solution is not None and ParsedGitUrl.looks_remote(solution):
173
192
  _confirm_remote(solution, trust=trust)
@@ -184,8 +203,8 @@ def run(
184
203
  task_binding = trap_yaml_loader.resolve_task(task)
185
204
  if ParsedGitUrl.looks_remote(task_binding.source):
186
205
  _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
206
+ traptask_yaml_loader = TraptaskLoader.from_task_binding(
207
+ task_binding, trap_yaml_loader.trap_dir, setup=setup_task, workspace_root=workspace.resolve()
189
208
  )
190
209
  except (GitOpsError, ConfigError, subprocess.CalledProcessError) as e:
191
210
  raise _die(e) from None
@@ -204,14 +223,14 @@ def run(
204
223
 
205
224
  started_at_local = datetime.now()
206
225
  ts = started_at_local.isoformat(timespec="seconds")
207
- report_handle = ReportHandle(workspace.resolve(), task_binding.alias, ts)
226
+ ws = Workspace(workspace.resolve(), SolutionIdentity.from_spec(solution).dirname, task_binding.alias)
208
227
 
209
228
  runner = TaskRunner(
210
229
  trap_config=trap_yaml_loader.config,
211
230
  trap_dir=trap_yaml_loader.trap_dir,
212
231
  traptask_config=traptask_yaml_loader.traptask,
213
232
  traptask_dir=traptask_yaml_loader.traptask_dir,
214
- run_dir=report_handle.run_dir,
233
+ run_dir=ws.run_dir(ts),
215
234
  cost_enabled=cost,
216
235
  )
217
236
  prog_console = console if output == OutputFormat.rich else None
@@ -233,8 +252,8 @@ def run(
233
252
  except Exception:
234
253
  environment_info = None
235
254
 
236
- report_data = report_handle.save(
237
- case_results=case_results,
255
+ report_data = ReportData.from_run(
256
+ cases_results=case_results,
238
257
  trap_config=trap_yaml_loader.config,
239
258
  started_at_utc=started_at_local.astimezone(UTC),
240
259
  finished_at_utc=finished_at_utc,
@@ -242,7 +261,11 @@ def run(
242
261
  provenance=provenance,
243
262
  environment=environment_info,
244
263
  )
264
+ ws.save_as_report(ts, report_data)
245
265
  renderer_factory(output).render(report_data)
266
+ if output == OutputFormat.rich:
267
+ # Where the artifacts landed — the run id doubles as the `--run` handle.
268
+ console.print(f"[dim]run {ts} → {ws.report_json_path(ts)}[/dim]")
246
269
  # trap reports facts, not a verdict: a completed run exits 0 regardless of per-case
247
270
  # exit codes or scores (read the grader / report.json to gate CI). trap-level
248
271
  # failures (bad config, git errors) still exit non-zero via _die.
@@ -250,43 +273,47 @@ def run(
250
273
 
251
274
  @app.command()
252
275
  def report(
253
- task: Annotated[str | None, typer.Argument()] = None,
254
- run: Annotated[str, typer.Argument()] = "latest",
255
276
  solution: Annotated[
256
277
  str | None,
257
- typer.Option("--solution", help="Local solution path holding trap.yaml (default: cwd)."),
278
+ typer.Argument(help="Local solution path holding trap.yaml (default: cwd)."),
279
+ ] = None,
280
+ task: Annotated[
281
+ str | None,
282
+ typer.Option("--task", help="Task alias from trap.yaml (default: the first task)."),
258
283
  ] = None,
284
+ run: Annotated[str, typer.Option("--run", "-r", help="Which run to display.")] = "latest",
285
+ workspace: Annotated[Path, typer.Option("--workspace", "-w")] = Path(Workspace.DEFAULT_DIRNAME),
259
286
  output: Annotated[OutputFormat, typer.Option("--output", "-o")] = OutputFormat.rich,
260
- workspace: Annotated[Path, typer.Option("--workspace", "-w")] = Path(".trap"),
261
287
  ) -> None:
262
288
  """Display a report for a task (defaults to latest run)."""
263
289
  try:
264
290
  task_alias = TrapLoader.from_solution(solution).resolve_task(task).alias
265
291
  except (GitOpsError, ConfigError) as e:
266
292
  raise _die(e) from None
267
- handle = ReportHandle(workspace.resolve(), task_alias, run)
293
+ ws = Workspace(workspace.resolve(), SolutionIdentity.from_spec(solution).dirname, task_alias)
268
294
  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
295
+ report_data = ws.load(run)
296
+ except FileNotFoundError as e:
297
+ raise _no_report(ws, e) from None
272
298
  renderer_factory(output).render(report_data)
273
299
 
274
300
 
275
301
  @app.command()
276
302
  def submit(
303
+ solution: Annotated[
304
+ str | None,
305
+ typer.Argument(help="Local solution path holding trap.yaml (default: cwd)."),
306
+ ] = None,
277
307
  task: Annotated[
278
308
  str | None,
279
- typer.Argument(
280
- help="Task name (defaults to first task in trap.yaml). "
309
+ typer.Option(
310
+ "--task",
311
+ help="Task alias from trap.yaml (default: the first task). "
281
312
  "Used as both the local run dir and the trapstreet task_id.",
282
313
  ),
283
314
  ] = None,
284
- solution: Annotated[
285
- str | None,
286
- typer.Option("--solution", help="Local solution path holding trap.yaml (default: cwd)."),
287
- ] = None,
288
- workspace: Annotated[Path, typer.Option("--workspace", "-w")] = Path(".trap"),
289
315
  run: Annotated[str, typer.Option("--run", "-r", help="Which run to upload.")] = "latest",
316
+ workspace: Annotated[Path, typer.Option("--workspace", "-w")] = Path(Workspace.DEFAULT_DIRNAME),
290
317
  allow_unanchored: Annotated[
291
318
  bool,
292
319
  typer.Option(
@@ -298,8 +325,8 @@ def submit(
298
325
  ) -> None:
299
326
  """Upload a report.json to trapstreet.
300
327
 
301
- Reads from the .trap/<task>/<run>/report.json workspace that `tp run`
302
- populated.
328
+ Reads from the .trap/runs/<solution>/<task>/<run>/report.json workspace
329
+ that `tp run` populated.
303
330
  """
304
331
  stored = AuthStore().load()
305
332
  # priority: TRAPSTREET_URL env > stored > default
@@ -315,17 +342,17 @@ def submit(
315
342
  task_alias = TrapLoader.from_solution(solution).resolve_task(task).alias
316
343
  except (GitOpsError, ConfigError) as e:
317
344
  raise _die(e) from None
318
- report_handle = ReportHandle(workspace.resolve(), task_alias, run)
345
+ ws = Workspace(workspace.resolve(), SolutionIdentity.from_spec(solution).dirname, task_alias)
319
346
  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
347
+ report_data = ws.load(run)
348
+ except FileNotFoundError as e:
349
+ raise _no_report(ws, e) from None
323
350
  # Repeat the unanchored-provenance gate at upload time — the report records
324
351
  # what `tp run` saw, so the checkouts aren't re-probed here.
325
352
  _confirm_unanchored(
326
353
  report_data.provenance, allow=allow_unanchored or _env_truthy("TRAP_ALLOW_UNANCHORED")
327
354
  )
328
- report_path = report_handle.report_json_path
355
+ report_path = ws.report_json_path(run)
329
356
 
330
357
  client = ApiClient(resolved_server, resolved_key)
331
358
  try:
@@ -0,0 +1,19 @@
1
+ from trap.display.progress import CaseProgress
2
+ from trap.display.report import (
3
+ BaseRenderer,
4
+ JsonRenderer,
5
+ OutputFormat,
6
+ RichRenderer,
7
+ renderer_factory,
8
+ )
9
+ from trap.display.submit import render_submit_result
10
+
11
+ __all__ = [
12
+ "BaseRenderer",
13
+ "CaseProgress",
14
+ "JsonRenderer",
15
+ "OutputFormat",
16
+ "RichRenderer",
17
+ "render_submit_result",
18
+ "renderer_factory",
19
+ ]
@@ -1,6 +1,10 @@
1
+ """Report renderers: turn a ReportData into terminal output (rich table or JSON)."""
2
+
1
3
  from __future__ import annotations
2
4
 
5
+ import enum
3
6
  import json
7
+ import sys
4
8
 
5
9
  from rich import box
6
10
  from rich.console import Console
@@ -10,7 +14,21 @@ from rich.table import Table
10
14
  from rich.text import Text
11
15
 
12
16
  from trap.models import CaseResult, ReportData
13
- from trap.report.base import BaseRenderer
17
+
18
+
19
+ class OutputFormat(enum.StrEnum):
20
+ rich = "rich"
21
+ json = "json"
22
+
23
+
24
+ class BaseRenderer:
25
+ def render(self, data: ReportData) -> None:
26
+ raise NotImplementedError
27
+
28
+
29
+ class JsonRenderer(BaseRenderer):
30
+ def render(self, data: ReportData) -> None:
31
+ sys.stdout.write(data.model_dump_json(indent=2) + "\n")
14
32
 
15
33
 
16
34
  class RichRenderer(BaseRenderer):
@@ -114,3 +132,7 @@ class RichRenderer(BaseRenderer):
114
132
  def render(self, data: ReportData) -> None:
115
133
  self.console.print(self._build_summary(data))
116
134
  self.console.print(self._build_table(data))
135
+
136
+
137
+ def renderer_factory(fmt: OutputFormat) -> BaseRenderer:
138
+ return {OutputFormat.rich: RichRenderer, OutputFormat.json: JsonRenderer}[fmt]()
@@ -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
@@ -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,4 +0,0 @@
1
- from trap.display.progress import CaseProgress
2
- from trap.display.submit import render_submit_result
3
-
4
- __all__ = ["CaseProgress", "render_submit_result"]
@@ -1,19 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import enum
4
-
5
- from trap.report.base import BaseRenderer
6
- from trap.report.handle import ReportHandle
7
- from trap.report.json import JsonRenderer
8
- from trap.report.rich import RichRenderer
9
-
10
- __all__ = ["BaseRenderer", "JsonRenderer", "OutputFormat", "ReportHandle", "RichRenderer", "renderer_factory"]
11
-
12
-
13
- class OutputFormat(enum.StrEnum):
14
- rich = "rich"
15
- json = "json"
16
-
17
-
18
- def renderer_factory(fmt: OutputFormat) -> BaseRenderer:
19
- return {OutputFormat.rich: RichRenderer, OutputFormat.json: JsonRenderer}[fmt]()
@@ -1,8 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from trap.models import ReportData
4
-
5
-
6
- class BaseRenderer:
7
- def render(self, data: ReportData) -> None:
8
- raise NotImplementedError
@@ -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())
@@ -1,11 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import sys
4
-
5
- from trap.models import ReportData
6
- from trap.report.base import BaseRenderer
7
-
8
-
9
- class JsonRenderer(BaseRenderer):
10
- def render(self, data: ReportData) -> None:
11
- sys.stdout.write(data.model_dump_json(indent=2) + "\n")
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes