trap-cli 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. trap/__init__.py +6 -0
  2. trap/_version.py +24 -0
  3. trap/auth/__init__.py +15 -0
  4. trap/auth/client.py +56 -0
  5. trap/auth/login.py +39 -0
  6. trap/auth/oauth.py +82 -0
  7. trap/auth/store.py +39 -0
  8. trap/cli/__init__.py +274 -0
  9. trap/cli/_auth.py +103 -0
  10. trap/cost/__init__.py +3 -0
  11. trap/cost/calculator.py +41 -0
  12. trap/cost/providers.py +122 -0
  13. trap/cost/proxy.py +186 -0
  14. trap/display/__init__.py +4 -0
  15. trap/display/progress.py +61 -0
  16. trap/display/submit.py +20 -0
  17. trap/environment/__init__.py +3 -0
  18. trap/environment/detector.py +81 -0
  19. trap/git_ops/__init__.py +13 -0
  20. trap/git_ops/base.py +10 -0
  21. trap/git_ops/local.py +60 -0
  22. trap/git_ops/remote.py +63 -0
  23. trap/git_ops/rev.py +119 -0
  24. trap/git_ops/url.py +98 -0
  25. trap/loader/__init__.py +5 -0
  26. trap/loader/errors.py +7 -0
  27. trap/loader/trap_yaml.py +99 -0
  28. trap/loader/traptask_yaml.py +85 -0
  29. trap/models/__init__.py +36 -0
  30. trap/models/cost.py +36 -0
  31. trap/models/environment.py +24 -0
  32. trap/models/provenance.py +20 -0
  33. trap/models/report.py +61 -0
  34. trap/models/results.py +17 -0
  35. trap/models/trap_yaml.py +64 -0
  36. trap/models/traptask_yaml.py +58 -0
  37. trap/report/__init__.py +19 -0
  38. trap/report/base.py +8 -0
  39. trap/report/handle.py +54 -0
  40. trap/report/json.py +11 -0
  41. trap/report/rich.py +116 -0
  42. trap/runner/__init__.py +5 -0
  43. trap/runner/capture.py +34 -0
  44. trap/runner/grader.py +36 -0
  45. trap/runner/judge.py +50 -0
  46. trap/runner/layout.py +36 -0
  47. trap/runner/proc.py +122 -0
  48. trap/runner/solution.py +80 -0
  49. trap/runner/task.py +102 -0
  50. trap_cli-0.0.1.dist-info/METADATA +88 -0
  51. trap_cli-0.0.1.dist-info/RECORD +54 -0
  52. trap_cli-0.0.1.dist-info/WHEEL +4 -0
  53. trap_cli-0.0.1.dist-info/entry_points.txt +2 -0
  54. trap_cli-0.0.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,80 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import TYPE_CHECKING
5
+
6
+ from trap.cost import CostProxy
7
+ from trap.models import CaseResult
8
+ from trap.models.cost import CaseCost
9
+ from trap.runner.proc import CapturedSubprocess
10
+
11
+ if TYPE_CHECKING:
12
+ from trap.runner.layout import CaseLayout
13
+ from trap.runner.task import TaskRunner
14
+
15
+
16
+ class SolutionRunner:
17
+ def __init__(self, runner: TaskRunner, case_id: str, layout: CaseLayout) -> None:
18
+ self.runner = runner
19
+ self.case_id = case_id
20
+ self.case_inputs_dir = runner.task_inputs_dir / case_id # task-repo side
21
+ self.layout = layout # workspace side
22
+
23
+ @property
24
+ def _stdin(self) -> str:
25
+ stdin = self.runner.trap_config.stdin
26
+ if stdin:
27
+ return (self.case_inputs_dir / stdin).read_text()
28
+ return ""
29
+
30
+ @property
31
+ def _manifest(self) -> str:
32
+ return json.dumps(
33
+ {
34
+ "inputs_dir": str(self.case_inputs_dir.resolve()),
35
+ "outputs_dir": str(self.layout.outputs_dir.resolve()),
36
+ }
37
+ )
38
+
39
+ def run(self) -> CaseResult:
40
+ self.layout.outputs_dir.mkdir(parents=True, exist_ok=True)
41
+
42
+ proxy: CostProxy | None = None
43
+ proxy_env: dict[str, str] = {}
44
+ if self.runner.cost_enabled:
45
+ try:
46
+ proxy = CostProxy()
47
+ proxy.start()
48
+ proxy_env = proxy.env_overrides
49
+ except Exception:
50
+ pass
51
+
52
+ # A timeout is folded into the result (exit 124, partial output) rather than
53
+ # raised — for the solution it counts as "did not complete", never a crash.
54
+ config = self.runner.trap_config
55
+ proc = CapturedSubprocess(
56
+ config.cmd,
57
+ manifest_envvar=config.manifest_envvar,
58
+ timeout=config.timeout,
59
+ cwd=self.runner.trap_dir,
60
+ manifest=self._manifest,
61
+ capture=self.layout.solution_capture,
62
+ stdin=self._stdin,
63
+ env_extra=proxy_env,
64
+ )
65
+ case_cost: CaseCost | None = None
66
+ try:
67
+ result = proc.run()
68
+ finally:
69
+ if proxy is not None:
70
+ partial = proxy.stop()
71
+ if partial.calls > 0:
72
+ case_cost = partial
73
+
74
+ return CaseResult(
75
+ case_id=self.case_id,
76
+ exit_code=result.exit_code,
77
+ duration=result.duration,
78
+ metrics=None,
79
+ cost=case_cost,
80
+ )
trap/runner/task.py ADDED
@@ -0,0 +1,102 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable, Iterable, Iterator
4
+ from datetime import datetime
5
+ from functools import cached_property
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from trap.models import CaseResult, TrapConfig, TraptaskCase, TraptaskConfig
10
+ from trap.runner.grader import GraderRunner
11
+ from trap.runner.judge import JudgeRunner
12
+ from trap.runner.layout import CaseLayout
13
+ from trap.runner.solution import SolutionRunner
14
+
15
+
16
+ class TaskRunner:
17
+ def __init__(
18
+ self,
19
+ trap_config: TrapConfig,
20
+ trap_dir: Path,
21
+ traptask_dir: Path,
22
+ traptask_config: TraptaskConfig,
23
+ run_dir: Path,
24
+ cost_enabled: bool = True,
25
+ ) -> None:
26
+ self.trap_config = trap_config
27
+ self.trap_dir = trap_dir
28
+ self.traptask_config = traptask_config
29
+ self.traptask_dir = traptask_dir
30
+ self.run_dir = run_dir
31
+ self.cost_enabled = cost_enabled
32
+
33
+ @cached_property
34
+ def task_inputs_dir(self) -> Path:
35
+ """The task's inputs/ dir (traptask_dir / dirs.inputs), resolved once on first use."""
36
+ return (self.traptask_dir / self.traptask_config.dirs.inputs).resolve()
37
+
38
+ @cached_property
39
+ def task_expected_dir(self) -> Path:
40
+ """The task's expected/ dir (traptask_dir / dirs.expected), resolved once on first use."""
41
+ return (self.traptask_dir / self.traptask_config.dirs.expected).resolve()
42
+
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
+ def _iter_cases(
56
+ self,
57
+ cases: Iterable[TraptaskCase],
58
+ *,
59
+ fail_fast: bool = False,
60
+ on_case_start: Callable[[str], None] | None = None,
61
+ on_case_done: Callable[[CaseResult], None] | None = None,
62
+ ) -> Iterator[CaseResult]:
63
+ # TODO: parallelize case runs, but judge cases sequentially in the same order as case runs
64
+ for case in cases:
65
+ if on_case_start is not None:
66
+ on_case_start(case.id)
67
+ layout = CaseLayout.for_case(self.run_dir, case.id)
68
+ case_result = SolutionRunner(self, case.id, layout).run()
69
+ if self.traptask_config.judge is not None:
70
+ # A broken judge is folded into an error metric by the runner (never
71
+ # crashes the run); we just attach whatever it returns.
72
+ metrics = JudgeRunner(self, case.id, layout).run()
73
+ case_result = case_result.model_copy(update={"metrics": metrics})
74
+ if on_case_done is not None:
75
+ on_case_done(case_result)
76
+ yield case_result
77
+ if fail_fast and case_result.exit_code != 0:
78
+ break
79
+
80
+ def run(
81
+ self,
82
+ cases: Iterable[TraptaskCase],
83
+ *,
84
+ fail_fast: bool = False,
85
+ on_case_start: Callable[[str], None] | None = None,
86
+ on_case_done: Callable[[CaseResult], None] | None = None,
87
+ ) -> tuple[tuple[CaseResult, ...], Any]:
88
+
89
+ case_results = tuple(
90
+ self._iter_cases(
91
+ cases, fail_fast=fail_fast, on_case_start=on_case_start, on_case_done=on_case_done
92
+ )
93
+ )
94
+
95
+ grader_metrics = None
96
+ if self.traptask_config.grader is not None:
97
+ # A broken grader is folded into an error metric by the runner (the run
98
+ # still completes and the report still saves).
99
+ grader_metrics = GraderRunner(self, case_results).run()
100
+
101
+ self._update_latest()
102
+ return case_results, grader_metrics
@@ -0,0 +1,88 @@
1
+ Metadata-Version: 2.4
2
+ Name: trap-cli
3
+ Version: 0.0.1
4
+ Summary: tp — non-invasive CLI testing framework for AI workflows. Submits results to trapstreet.run.
5
+ Project-URL: Homepage, https://trapstreet.run
6
+ Project-URL: Repository, https://github.com/trapstreet/trap
7
+ Project-URL: Issues, https://github.com/trapstreet/trap/issues
8
+ Author-email: "trapstreet.run" <founder@trapstreet.run>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: agent,ai,benchmark,eval,llm,trapstreet,workflow
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3 :: Only
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Topic :: Software Development :: Testing
22
+ Requires-Python: >=3.13
23
+ Requires-Dist: gitpython>=3.1
24
+ Requires-Dist: httpx>=0.27
25
+ Requires-Dist: psutil>=7.2.2
26
+ Requires-Dist: py-cpuinfo>=9.0.0
27
+ Requires-Dist: pydantic>=2.0
28
+ Requires-Dist: pyyaml>=6.0
29
+ Requires-Dist: rich>=13.0
30
+ Requires-Dist: tokencost>=0.1.26
31
+ Requires-Dist: typer>=0.12
32
+ Description-Content-Type: text/markdown
33
+
34
+ # trap
35
+
36
+ [![CI](https://github.com/trapstreet/trap/actions/workflows/ci.yml/badge.svg)](https://github.com/trapstreet/trap/actions/workflows/ci.yml)
37
+ [![PyPI](https://img.shields.io/pypi/v/trap-cli)](https://pypi.org/project/trap-cli/)
38
+ [![Python](https://img.shields.io/pypi/pyversions/trap-cli)](https://pypi.org/project/trap-cli/)
39
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://opensource.org/licenses/MIT)
40
+
41
+ **Non-invasive CLI testing framework for AI prompts, agents, and workflows.**
42
+
43
+ trap treats any solution as a black box — it invokes it as a subprocess, captures outputs, then optionally scores them through a language-agnostic judge and grader. The solution doesn't need to import trap or know it exists.
44
+
45
+ ## Install
46
+
47
+ ```bash
48
+ # requires uv — https://docs.astral.sh/uv/getting-started/installation/
49
+
50
+ # from PyPI
51
+ uv tool install trap-cli
52
+
53
+ # from git (latest main)
54
+ uv tool install "git+https://github.com/trapstreet/trap.git"
55
+ ```
56
+
57
+ The command is `tp`.
58
+
59
+ ## How it works
60
+
61
+ trap runs your solution as a subprocess with one env var (`TRAP_MANIFEST` — an input dir and
62
+ an output dir), captures what it writes, then optionally scores it through a judge (per case)
63
+ and a grader (overall). See the [full flow and IO contract](docs/index.md).
64
+
65
+ Two roles, two directories, one IO contract:
66
+
67
+ - **Solution author** — writes `trap.yaml` and the solution code
68
+ - **Task author** — writes `traptask.yaml`, `inputs/`, `expected/`, and optional judge/grader scripts
69
+
70
+ ## Quick start
71
+
72
+ ```bash
73
+ # from examples/echo/solution/
74
+ tp run # run all cases
75
+ tp run -t smoke # run only cases tagged `smoke`
76
+ ```
77
+
78
+ ## Documentation
79
+
80
+ - [Quick start](docs/quickstart.md)
81
+ - [Writing a solution](docs/guides/writing-solution.md)
82
+ - [Writing a task](docs/guides/writing-task.md)
83
+ - [CLI reference](docs/reference/cli.md)
84
+ - [IO contract](docs/reference/io-contract.md)
85
+
86
+ ## License
87
+
88
+ MIT
@@ -0,0 +1,54 @@
1
+ trap/__init__.py,sha256=UA2VQ2W2CY2JiBDDO7nQeYJUJuniitUWzKzFVXKGzBI,184
2
+ trap/_version.py,sha256=8OsTLsIVB9D0HdPTmt5rVwyVUBe9xTVGkRslXicxzkM,520
3
+ trap/auth/__init__.py,sha256=5bkDjtwT8GwDe_yof0i5RK35mmqDKw60UQjkVc1J0Ns,394
4
+ trap/auth/client.py,sha256=6p-kJmOstolFbZ0CUgxrISxOprUawvNdhtDtBEFfC2Y,2059
5
+ trap/auth/login.py,sha256=GfK2vxWZu3Zbj6U4QS0Wr1nmMSsYk5vbZpqdapj5UTc,1283
6
+ trap/auth/oauth.py,sha256=yDqiwV4PX0xRzMta1P2o0bHpIvTAtgqhc_5EyOAKebE,2726
7
+ trap/auth/store.py,sha256=GAw1HdxaQWPJG7ybHx1FriB71Y2ZnS81OKA5TwGG49s,1010
8
+ trap/cli/__init__.py,sha256=VuLDC4t9uOhsqBUwXc8vtavyO44xagjKJbG2X-WLC-k,10728
9
+ trap/cli/_auth.py,sha256=pg-0UonfqUOpVrzyPzMjTvu1v3wxNi4s-GL6r3dBwrE,3615
10
+ trap/cost/__init__.py,sha256=dZgUNIwI_1TJGNXOy7LSRx0rWwGnVDDW2dl2mkEKcX0,54
11
+ trap/cost/calculator.py,sha256=jo4CQnqEr_UTn4aNjv7n7UUnIPzplfs3leZ4nhRLep4,1710
12
+ trap/cost/providers.py,sha256=BeRIwjsEDi7ASxQ9CIBwOKme9IAPF1MAjcnhat7PbFM,4965
13
+ trap/cost/proxy.py,sha256=LJPqcYkHrWJBMuzC0P-8ozfm2nOI5wJV-3O1Mq8JtLg,7246
14
+ trap/display/__init__.py,sha256=3AFDEPoPvDRFeyn94IAWdw6DZIHNCuAGx7982Ez_Uto,152
15
+ trap/display/progress.py,sha256=FCM1BuEb-z5QwvCwlJ9xnUgZVEmNmAIRLrdVgGl9s68,1954
16
+ trap/display/submit.py,sha256=rA12JI9zIRxybRTEzZd3BMfEsVzCZHY9VbdcgZlYOgY,719
17
+ trap/environment/__init__.py,sha256=TYHi2fnhRAZ3Am-GJo5-J89XY5QpKZEKYRHX1Er7j9c,77
18
+ trap/environment/detector.py,sha256=QJLaQSWtaQEc2Vk5ew2-AEPulamEjXybY0iAB2mUwwc,2632
19
+ trap/git_ops/__init__.py,sha256=eA9RWw-vFK4B2x4NAkwXgckrEDpDgb64gtOigJQ1pAg,293
20
+ trap/git_ops/base.py,sha256=hcbcjU6gvl3XtITvwwa5hpbjylfh2Q2rE2w5jungUkQ,252
21
+ trap/git_ops/local.py,sha256=2ANpyeJ7mOnIlqFLw4398WoVAYHCeRQ4RnKbrdm7d8k,2470
22
+ trap/git_ops/remote.py,sha256=L_tBQVgQ4ReMKWJ6fUEAtoKHGg1m5_did6DuYw_I0co,2596
23
+ trap/git_ops/rev.py,sha256=IxnvU_NsiO9YI2f9XAz3MONF2jL14hXMQnVkek59Y0g,4402
24
+ trap/git_ops/url.py,sha256=6NqpWixb6v2e8ultqwcmTn-yfs0QpPOkoZNSkJiK8Eg,3861
25
+ trap/loader/__init__.py,sha256=cFfdpu4_LR4wSPEk9Q5f5hr-fmyzcMjyk0S_DV3dO9Q,167
26
+ trap/loader/errors.py,sha256=rGe4VT_06mVp_CgVKj-JbvLAEQIOXaJFlM3it7fYdbs,325
27
+ trap/loader/trap_yaml.py,sha256=-IVHh8pAVT2IhznddUJlHOiFZkDXcBf5eXZCyCBUge0,4218
28
+ trap/loader/traptask_yaml.py,sha256=3ITDiJ5fVYZgVjVh_udwH3RFxlxcWtN9394v4Fezp7A,4168
29
+ trap/models/__init__.py,sha256=RDpYjr_E7s0ynFajfdPSqaneCtRX03hPQ6nU2y4rGPI,755
30
+ trap/models/cost.py,sha256=K-FciMrWB2tXu6PEFVM-ZKfk8EZnR3_MS_hIpaxpC4Y,831
31
+ trap/models/environment.py,sha256=1C2e3Dp-XsUZLH2fgVrIpn3REBauDtpLaHw-VTEzceE,747
32
+ trap/models/provenance.py,sha256=fPUWa2Si5rT08vA5LtOZ5cvPRg7ZvVb-zEM-QMfHakE,705
33
+ trap/models/report.py,sha256=ojI8zXMSxtsN1Lh1T1dtWS1WKD9GQLOAFG7u_DOIVg0,2374
34
+ trap/models/results.py,sha256=_Q8PT8gOFZNxMarEJyFML8LIbmxbDMyVmcuWD_z0c_A,432
35
+ trap/models/trap_yaml.py,sha256=xRYmwjknYJmMqDk8Y6S6kNxpcQrPZPRBI26xsjlHc14,2977
36
+ trap/models/traptask_yaml.py,sha256=htD7G6zJcpOPFH8LcrRejr1tmS8hG52VNyj5PIImMPQ,2404
37
+ trap/report/__init__.py,sha256=snW1TEmWZYdbGXkvlXMF0b14rOXYXNolP6jJxzry5Nc,547
38
+ trap/report/base.py,sha256=UGXSHiD3Te3_0UyzkMADpSdw8Rs7yFFGuz5KhfEPgiY,175
39
+ trap/report/handle.py,sha256=Piitl2yOjgMwMtJMD8jrrwD7BLc5ht0SPYa4YvV75XQ,1676
40
+ trap/report/json.py,sha256=9RUtQFGjyAOei-q8C7qi95-XrlogoA3gfMjC0KTaOLY,273
41
+ trap/report/rich.py,sha256=CbSd3i0hBJSOtYSXJiqj-HK7-Lzz89syg_hgfmTwiHA,5023
42
+ trap/runner/__init__.py,sha256=2VleaPrx206KgFUdBEe2CjCIcNrTZMOCBkZ8mij3O4A,102
43
+ trap/runner/capture.py,sha256=pMHsGUZvGS-l9Ngi9XPcZlBa-64p_eTBaRXViO1wGVY,1017
44
+ trap/runner/grader.py,sha256=zaRK_tBtzBtLAMI54moA4_yxaS5My1tJWKK743PXzqA,1241
45
+ trap/runner/judge.py,sha256=jnzwNp-IPYBCNTb7iCmXoKjUaVJd4DDOIVUp0Giv5Ng,1844
46
+ trap/runner/layout.py,sha256=em-q1hZHSbAYaJWwBNmU-GhmFgbIt20Bvsm6kyvFmDA,1118
47
+ trap/runner/proc.py,sha256=GaAWsxdIpvNmeupwk9MVKkHhG-zDdzo3AVqp6X_zbuk,4972
48
+ trap/runner/solution.py,sha256=AWG0d2hr4yS9yFfpSF0ycTTKJTDOwsQg6Q4Q2eQA1-g,2498
49
+ trap/runner/task.py,sha256=2j6TInZhHKCANG4XogyIgydw2BQjdZxJ0agssVgF49Y,4033
50
+ trap_cli-0.0.1.dist-info/METADATA,sha256=LYN_Yn8E-gFgFjpotNcE9TmSaYvhvIRHIGUPW48-qgg,3188
51
+ trap_cli-0.0.1.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
52
+ trap_cli-0.0.1.dist-info/entry_points.txt,sha256=wLIOVpx-cjscwnuIRPTuuXLyXs7K31r68xA7Gwt-Bmk,36
53
+ trap_cli-0.0.1.dist-info/licenses/LICENSE,sha256=ZZ7IeDjFVJJOC6qoVFoHWcysqbip79BElVZvAb5ScTY,1071
54
+ trap_cli-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ tp = trap.cli:app
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 trapstreet.run
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.