trap-cli 0.0.1__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/.gitignore +45 -0
- trap_cli-0.0.1/LICENSE +21 -0
- trap_cli-0.0.1/PKG-INFO +88 -0
- trap_cli-0.0.1/README.md +55 -0
- trap_cli-0.0.1/examples/echo/task/README.md +39 -0
- trap_cli-0.0.1/pyproject.toml +107 -0
- trap_cli-0.0.1/src/trap/__init__.py +6 -0
- trap_cli-0.0.1/src/trap/_version.py +24 -0
- trap_cli-0.0.1/src/trap/auth/__init__.py +15 -0
- trap_cli-0.0.1/src/trap/auth/client.py +56 -0
- trap_cli-0.0.1/src/trap/auth/login.py +39 -0
- trap_cli-0.0.1/src/trap/auth/oauth.py +82 -0
- trap_cli-0.0.1/src/trap/auth/store.py +39 -0
- trap_cli-0.0.1/src/trap/cli/__init__.py +274 -0
- trap_cli-0.0.1/src/trap/cli/_auth.py +103 -0
- trap_cli-0.0.1/src/trap/cost/__init__.py +3 -0
- trap_cli-0.0.1/src/trap/cost/calculator.py +41 -0
- trap_cli-0.0.1/src/trap/cost/providers.py +122 -0
- trap_cli-0.0.1/src/trap/cost/proxy.py +186 -0
- trap_cli-0.0.1/src/trap/display/__init__.py +4 -0
- trap_cli-0.0.1/src/trap/display/progress.py +61 -0
- trap_cli-0.0.1/src/trap/display/submit.py +20 -0
- trap_cli-0.0.1/src/trap/environment/__init__.py +3 -0
- trap_cli-0.0.1/src/trap/environment/detector.py +81 -0
- trap_cli-0.0.1/src/trap/git_ops/__init__.py +13 -0
- trap_cli-0.0.1/src/trap/git_ops/base.py +10 -0
- trap_cli-0.0.1/src/trap/git_ops/local.py +60 -0
- trap_cli-0.0.1/src/trap/git_ops/remote.py +63 -0
- trap_cli-0.0.1/src/trap/git_ops/rev.py +119 -0
- trap_cli-0.0.1/src/trap/git_ops/url.py +98 -0
- trap_cli-0.0.1/src/trap/loader/__init__.py +5 -0
- trap_cli-0.0.1/src/trap/loader/errors.py +7 -0
- trap_cli-0.0.1/src/trap/loader/trap_yaml.py +99 -0
- trap_cli-0.0.1/src/trap/loader/traptask_yaml.py +85 -0
- trap_cli-0.0.1/src/trap/models/__init__.py +36 -0
- trap_cli-0.0.1/src/trap/models/cost.py +36 -0
- trap_cli-0.0.1/src/trap/models/environment.py +24 -0
- trap_cli-0.0.1/src/trap/models/provenance.py +20 -0
- trap_cli-0.0.1/src/trap/models/report.py +61 -0
- trap_cli-0.0.1/src/trap/models/results.py +17 -0
- trap_cli-0.0.1/src/trap/models/trap_yaml.py +64 -0
- trap_cli-0.0.1/src/trap/models/traptask_yaml.py +58 -0
- trap_cli-0.0.1/src/trap/report/__init__.py +19 -0
- trap_cli-0.0.1/src/trap/report/base.py +8 -0
- trap_cli-0.0.1/src/trap/report/handle.py +54 -0
- trap_cli-0.0.1/src/trap/report/json.py +11 -0
- trap_cli-0.0.1/src/trap/report/rich.py +116 -0
- trap_cli-0.0.1/src/trap/runner/__init__.py +5 -0
- trap_cli-0.0.1/src/trap/runner/capture.py +34 -0
- trap_cli-0.0.1/src/trap/runner/grader.py +36 -0
- trap_cli-0.0.1/src/trap/runner/judge.py +50 -0
- trap_cli-0.0.1/src/trap/runner/layout.py +36 -0
- trap_cli-0.0.1/src/trap/runner/proc.py +122 -0
- trap_cli-0.0.1/src/trap/runner/solution.py +80 -0
- trap_cli-0.0.1/src/trap/runner/task.py +102 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
LOCAL*
|
|
2
|
+
.env
|
|
3
|
+
# Python
|
|
4
|
+
__pycache__/
|
|
5
|
+
*.py[cod]
|
|
6
|
+
*.pyo
|
|
7
|
+
*.pyd
|
|
8
|
+
*.so
|
|
9
|
+
|
|
10
|
+
# Distribution / packaging
|
|
11
|
+
dist/
|
|
12
|
+
build/
|
|
13
|
+
*.egg-info/
|
|
14
|
+
.eggs/
|
|
15
|
+
|
|
16
|
+
# uv / virtual envs
|
|
17
|
+
.venv/
|
|
18
|
+
uv.lock~
|
|
19
|
+
|
|
20
|
+
# hatch-vcs generated
|
|
21
|
+
src/trap/_version.py
|
|
22
|
+
|
|
23
|
+
# trap runtime workspace
|
|
24
|
+
.trap/
|
|
25
|
+
|
|
26
|
+
# MkDocs generated site
|
|
27
|
+
site/
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# Testing
|
|
31
|
+
.pytest_cache/
|
|
32
|
+
.coverage
|
|
33
|
+
htmlcov/
|
|
34
|
+
.deepeval
|
|
35
|
+
|
|
36
|
+
# Ruff
|
|
37
|
+
.ruff_cache/
|
|
38
|
+
|
|
39
|
+
# macOS
|
|
40
|
+
.DS_Store
|
|
41
|
+
|
|
42
|
+
# Editors
|
|
43
|
+
.idea/
|
|
44
|
+
.vscode/
|
|
45
|
+
*.swp
|
trap_cli-0.0.1/LICENSE
ADDED
|
@@ -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.
|
trap_cli-0.0.1/PKG-INFO
ADDED
|
@@ -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
|
+
[](https://github.com/trapstreet/trap/actions/workflows/ci.yml)
|
|
37
|
+
[](https://pypi.org/project/trap-cli/)
|
|
38
|
+
[](https://pypi.org/project/trap-cli/)
|
|
39
|
+
[](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
|
trap_cli-0.0.1/README.md
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# trap
|
|
2
|
+
|
|
3
|
+
[](https://github.com/trapstreet/trap/actions/workflows/ci.yml)
|
|
4
|
+
[](https://pypi.org/project/trap-cli/)
|
|
5
|
+
[](https://pypi.org/project/trap-cli/)
|
|
6
|
+
[](https://opensource.org/licenses/MIT)
|
|
7
|
+
|
|
8
|
+
**Non-invasive CLI testing framework for AI prompts, agents, and workflows.**
|
|
9
|
+
|
|
10
|
+
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.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
# requires uv — https://docs.astral.sh/uv/getting-started/installation/
|
|
16
|
+
|
|
17
|
+
# from PyPI
|
|
18
|
+
uv tool install trap-cli
|
|
19
|
+
|
|
20
|
+
# from git (latest main)
|
|
21
|
+
uv tool install "git+https://github.com/trapstreet/trap.git"
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
The command is `tp`.
|
|
25
|
+
|
|
26
|
+
## How it works
|
|
27
|
+
|
|
28
|
+
trap runs your solution as a subprocess with one env var (`TRAP_MANIFEST` — an input dir and
|
|
29
|
+
an output dir), captures what it writes, then optionally scores it through a judge (per case)
|
|
30
|
+
and a grader (overall). See the [full flow and IO contract](docs/index.md).
|
|
31
|
+
|
|
32
|
+
Two roles, two directories, one IO contract:
|
|
33
|
+
|
|
34
|
+
- **Solution author** — writes `trap.yaml` and the solution code
|
|
35
|
+
- **Task author** — writes `traptask.yaml`, `inputs/`, `expected/`, and optional judge/grader scripts
|
|
36
|
+
|
|
37
|
+
## Quick start
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
# from examples/echo/solution/
|
|
41
|
+
tp run # run all cases
|
|
42
|
+
tp run -t smoke # run only cases tagged `smoke`
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Documentation
|
|
46
|
+
|
|
47
|
+
- [Quick start](docs/quickstart.md)
|
|
48
|
+
- [Writing a solution](docs/guides/writing-solution.md)
|
|
49
|
+
- [Writing a task](docs/guides/writing-task.md)
|
|
50
|
+
- [CLI reference](docs/reference/cli.md)
|
|
51
|
+
- [IO contract](docs/reference/io-contract.md)
|
|
52
|
+
|
|
53
|
+
## License
|
|
54
|
+
|
|
55
|
+
MIT
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Echo Task
|
|
2
|
+
|
|
3
|
+
Your solution receives a JSON object via stdin and must echo the `message` field to stdout.
|
|
4
|
+
|
|
5
|
+
## Input
|
|
6
|
+
|
|
7
|
+
A JSON object delivered via stdin:
|
|
8
|
+
|
|
9
|
+
```json
|
|
10
|
+
{"message": "Hello, World!"}
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Output
|
|
14
|
+
|
|
15
|
+
The value of `message`, printed to stdout (with a trailing newline).
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
Hello, World!
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Error handling
|
|
22
|
+
|
|
23
|
+
If the `message` field is missing, print an error to stderr and exit with code 1.
|
|
24
|
+
|
|
25
|
+
## Evaluation
|
|
26
|
+
|
|
27
|
+
Each case declares one or more assertions in its `expected.json` file.
|
|
28
|
+
The judge (`judge.py`) checks each declared assertion against the solution's stdout and exit code:
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
| Assertion | Passes when |
|
|
32
|
+
|---------------|------------------------------------------------------|
|
|
33
|
+
| `contains` | stdout contains the substring (case-insensitive) |
|
|
34
|
+
| `not_contains`| stdout does not contain the substring |
|
|
35
|
+
| `exact` | stdout matches exactly (after strip) |
|
|
36
|
+
| `regex` | stdout matches the regex pattern |
|
|
37
|
+
| `exit_code` | process exit code equals the value |
|
|
38
|
+
|
|
39
|
+
All declared assertions must pass for a case to pass.
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling", "hatch-vcs"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "trap-cli"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "tp — non-invasive CLI testing framework for AI workflows. Submits results to trapstreet.run."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
authors = [{ name = "trapstreet.run", email = "founder@trapstreet.run" }]
|
|
12
|
+
keywords = ["eval", "ai", "llm", "benchmark", "trapstreet", "agent", "workflow"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 3 - Alpha",
|
|
15
|
+
"Environment :: Console",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
22
|
+
"Programming Language :: Python :: 3.14",
|
|
23
|
+
"Topic :: Software Development :: Testing",
|
|
24
|
+
]
|
|
25
|
+
requires-python = ">=3.13"
|
|
26
|
+
dependencies = [
|
|
27
|
+
"typer>=0.12",
|
|
28
|
+
"pydantic>=2.0",
|
|
29
|
+
"pyyaml>=6.0",
|
|
30
|
+
"rich>=13.0",
|
|
31
|
+
"httpx>=0.27",
|
|
32
|
+
"tokencost>=0.1.26",
|
|
33
|
+
"gitpython>=3.1",
|
|
34
|
+
"psutil>=7.2.2",
|
|
35
|
+
"py-cpuinfo>=9.0.0",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[project.urls]
|
|
39
|
+
Homepage = "https://trapstreet.run"
|
|
40
|
+
Repository = "https://github.com/trapstreet/trap"
|
|
41
|
+
Issues = "https://github.com/trapstreet/trap/issues"
|
|
42
|
+
|
|
43
|
+
[project.scripts]
|
|
44
|
+
tp = "trap.cli:app"
|
|
45
|
+
|
|
46
|
+
[tool.hatch.version]
|
|
47
|
+
source = "vcs"
|
|
48
|
+
|
|
49
|
+
# Version comes from git tags shaped like `vX.Y.Z`. Options are forwarded
|
|
50
|
+
# to setuptools_scm; `fallback_version` keeps untagged builds working.
|
|
51
|
+
[tool.hatch.version.raw-options]
|
|
52
|
+
tag_regex = "^v(?P<version>.+)$"
|
|
53
|
+
git_describe_command = "git describe --dirty --tags --long --match v*"
|
|
54
|
+
fallback_version = "0.0.0.dev0"
|
|
55
|
+
|
|
56
|
+
[tool.hatch.build.hooks.vcs]
|
|
57
|
+
version-file = "src/trap/_version.py"
|
|
58
|
+
|
|
59
|
+
[tool.hatch.build.targets.wheel]
|
|
60
|
+
packages = ["src/trap"]
|
|
61
|
+
|
|
62
|
+
[tool.hatch.build.targets.sdist]
|
|
63
|
+
include = ["src/trap", "README.md", "pyproject.toml"]
|
|
64
|
+
|
|
65
|
+
[dependency-groups]
|
|
66
|
+
dev = [
|
|
67
|
+
"pytest>=8.0",
|
|
68
|
+
"pytest-cov>=5.0",
|
|
69
|
+
"ruff>=0.9",
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
[tool.uv.workspace]
|
|
73
|
+
exclude = ["examples/*"]
|
|
74
|
+
|
|
75
|
+
[tool.pyright]
|
|
76
|
+
# Type-check the package; tests intentionally feed pydantic models loose input
|
|
77
|
+
# (scalars/dicts its validators coerce), which strict typing would flag.
|
|
78
|
+
include = ["src"]
|
|
79
|
+
|
|
80
|
+
[tool.ruff]
|
|
81
|
+
line-length = 110
|
|
82
|
+
target-version = "py313"
|
|
83
|
+
extend-exclude = ["src/trap/_version.py"] # generated by hatch-vcs
|
|
84
|
+
|
|
85
|
+
[tool.ruff.lint]
|
|
86
|
+
select = ["E", "F", "I", "UP", "B", "C4", "RUF"]
|
|
87
|
+
|
|
88
|
+
[tool.ruff.lint.per-file-ignores]
|
|
89
|
+
"src/trap/_version.py" = ["RUF022"]
|
|
90
|
+
|
|
91
|
+
[tool.pytest.ini_options]
|
|
92
|
+
testpaths = ["tests"]
|
|
93
|
+
|
|
94
|
+
[tool.coverage.run]
|
|
95
|
+
source = ["trap"]
|
|
96
|
+
branch = true
|
|
97
|
+
omit = ["src/trap/_version.py"]
|
|
98
|
+
|
|
99
|
+
[tool.coverage.report]
|
|
100
|
+
show_missing = true
|
|
101
|
+
fail_under = 100
|
|
102
|
+
exclude_also = [
|
|
103
|
+
"if TYPE_CHECKING:",
|
|
104
|
+
"raise NotImplementedError",
|
|
105
|
+
"if __name__ == .__main__.:",
|
|
106
|
+
"@(abc\\.)?abstractmethod",
|
|
107
|
+
]
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.0.1'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 0, 1)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from trap.auth.client import ApiClient, ApiError
|
|
2
|
+
from trap.auth.login import BrowserProvider, TokenProvider
|
|
3
|
+
from trap.auth.oauth import OAuthCallbackServer
|
|
4
|
+
from trap.auth.store import DEFAULT_SERVER, AuthData, AuthStore
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"DEFAULT_SERVER",
|
|
8
|
+
"ApiClient",
|
|
9
|
+
"ApiError",
|
|
10
|
+
"AuthData",
|
|
11
|
+
"AuthStore",
|
|
12
|
+
"BrowserProvider",
|
|
13
|
+
"OAuthCallbackServer",
|
|
14
|
+
"TokenProvider",
|
|
15
|
+
]
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from functools import cached_property
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ApiError(Exception):
|
|
11
|
+
"""A trapstreet API call failed — bad status, unreachable server, or invalid token.
|
|
12
|
+
Carries a user-facing message; the CLI maps it to a clean error (no traceback)."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ApiClient:
|
|
16
|
+
"""Authenticated HTTP client for the trapstreet API."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, server: str, api_key: str, timeout: int = 30) -> None:
|
|
19
|
+
self._server = server.rstrip("/")
|
|
20
|
+
self._api_key = api_key
|
|
21
|
+
self._timeout = timeout
|
|
22
|
+
|
|
23
|
+
@cached_property
|
|
24
|
+
def _client(self) -> httpx.Client:
|
|
25
|
+
return httpx.Client(
|
|
26
|
+
base_url=self._server,
|
|
27
|
+
headers={
|
|
28
|
+
"authorization": f"Bearer {self._api_key}",
|
|
29
|
+
"content-type": "application/json",
|
|
30
|
+
},
|
|
31
|
+
timeout=self._timeout,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
def get_me(self) -> dict[str, Any]:
|
|
35
|
+
try:
|
|
36
|
+
resp = self._client.get("/api/me", timeout=10)
|
|
37
|
+
resp.raise_for_status()
|
|
38
|
+
return resp.json()
|
|
39
|
+
except httpx.HTTPStatusError as e:
|
|
40
|
+
if e.response.status_code == 401:
|
|
41
|
+
raise ApiError("token is invalid") from None
|
|
42
|
+
raise ApiError(f"server error ({e.response.status_code})") from None
|
|
43
|
+
except httpx.RequestError:
|
|
44
|
+
raise ApiError("server unreachable") from None
|
|
45
|
+
|
|
46
|
+
def submit(self, report_path: Path) -> dict[str, Any]:
|
|
47
|
+
# Content-addressed ingest: the task identity travels inside the report
|
|
48
|
+
# (provenance.task.{repo,commit}), not the URL — so no task_id path segment.
|
|
49
|
+
try:
|
|
50
|
+
resp = self._client.post("/api/submit", content=report_path.read_bytes())
|
|
51
|
+
resp.raise_for_status()
|
|
52
|
+
return resp.json()
|
|
53
|
+
except httpx.HTTPStatusError as e:
|
|
54
|
+
raise ApiError(f"http {e.response.status_code}: {e.response.text}") from None
|
|
55
|
+
except httpx.RequestError as e:
|
|
56
|
+
raise ApiError(f"connection error: {e}") from None
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from trap.auth.oauth import OAuthCallbackServer
|
|
4
|
+
from trap.auth.store import DEFAULT_SERVER, AuthData
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class TokenProvider:
|
|
8
|
+
def __init__(self, server: str, token: str) -> None:
|
|
9
|
+
self._server = server
|
|
10
|
+
self._token = token
|
|
11
|
+
|
|
12
|
+
@property
|
|
13
|
+
def pre_message(self) -> str:
|
|
14
|
+
return "authenticating with api_key"
|
|
15
|
+
|
|
16
|
+
def acquire(self) -> AuthData:
|
|
17
|
+
if not self._token:
|
|
18
|
+
raise ValueError("empty api_key")
|
|
19
|
+
return AuthData(server=self._server, api_key=self._token)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class BrowserProvider:
|
|
23
|
+
def __init__(self, server: str, timeout: int) -> None:
|
|
24
|
+
if server != DEFAULT_SERVER:
|
|
25
|
+
raise ValueError(
|
|
26
|
+
f"browser login is only supported on {DEFAULT_SERVER}. Use --with-token for custom servers."
|
|
27
|
+
)
|
|
28
|
+
self._cb = OAuthCallbackServer(server)
|
|
29
|
+
self._timeout = timeout
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def pre_message(self) -> str:
|
|
33
|
+
return f"opening [link={self._cb.auth_url}]{self._cb.auth_url}[/link]"
|
|
34
|
+
|
|
35
|
+
def acquire(self) -> AuthData:
|
|
36
|
+
if not self._cb.run(self._timeout):
|
|
37
|
+
raise TimeoutError(f"timed out after {self._timeout}s waiting for browser approval")
|
|
38
|
+
assert self._cb.auth_data is not None
|
|
39
|
+
return self._cb.auth_data
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import http.server
|
|
4
|
+
import socketserver
|
|
5
|
+
import threading
|
|
6
|
+
import urllib.parse
|
|
7
|
+
import webbrowser
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from trap.auth.store import AuthData
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class _CallbackHandler(http.server.BaseHTTPRequestHandler):
|
|
14
|
+
_SUCCESS_HTML = (
|
|
15
|
+
b"<!doctype html><meta charset=utf-8>"
|
|
16
|
+
b"<title>logged in</title>"
|
|
17
|
+
b"<style>body{font-family:ui-monospace,monospace;"
|
|
18
|
+
b"background:#0a0a0a;color:#ededed;padding:3em;}"
|
|
19
|
+
b"h1{color:#ff5f1f}</style>"
|
|
20
|
+
b"<h1>logged in</h1>"
|
|
21
|
+
b"<p>You can close this tab.</p>"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
def log_message(self, format: str, *args: Any) -> None:
|
|
25
|
+
return
|
|
26
|
+
|
|
27
|
+
def _respond(self, status: int, content_type: str, body: bytes) -> None:
|
|
28
|
+
self.send_response(status)
|
|
29
|
+
self.send_header("content-type", content_type)
|
|
30
|
+
self.end_headers()
|
|
31
|
+
self.wfile.write(body)
|
|
32
|
+
|
|
33
|
+
def do_GET(self) -> None:
|
|
34
|
+
params = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
|
|
35
|
+
|
|
36
|
+
api_key = (params.get("api_key") or [None])[0]
|
|
37
|
+
if not api_key:
|
|
38
|
+
self._respond(400, "text/plain", b"missing api_key in query string")
|
|
39
|
+
return
|
|
40
|
+
|
|
41
|
+
solution = (params.get("solution") or [None])[0]
|
|
42
|
+
srv: OAuthCallbackServer = self.server # type: ignore[assignment]
|
|
43
|
+
srv._receive(AuthData(server=srv._server_url, api_key=api_key, solution=solution))
|
|
44
|
+
self._respond(200, "text/html; charset=utf-8", self._SUCCESS_HTML)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class OAuthCallbackServer(socketserver.TCPServer):
|
|
48
|
+
"""Local HTTP server that receives the OAuth callback with api_key."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, server_url: str) -> None:
|
|
51
|
+
super().__init__(("127.0.0.1", 0), _CallbackHandler)
|
|
52
|
+
self._server_url = server_url.rstrip("/")
|
|
53
|
+
self._auth_data: AuthData | None = None
|
|
54
|
+
self._received = threading.Event()
|
|
55
|
+
|
|
56
|
+
def _receive(self, data: AuthData) -> None:
|
|
57
|
+
self._auth_data = data
|
|
58
|
+
self._received.set()
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def auth_data(self) -> AuthData | None:
|
|
62
|
+
return self._auth_data
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def port(self) -> int:
|
|
66
|
+
return self.server_address[1]
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def auth_url(self) -> str:
|
|
70
|
+
return f"{self._server_url}/cli/authorize?return=http://localhost:{self.port}/callback"
|
|
71
|
+
|
|
72
|
+
def run(self, timeout: int) -> bool:
|
|
73
|
+
"""Start server, open browser, wait for callback, shut down. Returns True if api_key received."""
|
|
74
|
+
threading.Thread(target=self.serve_forever, daemon=True).start()
|
|
75
|
+
try:
|
|
76
|
+
webbrowser.open(self.auth_url)
|
|
77
|
+
except Exception:
|
|
78
|
+
pass
|
|
79
|
+
received = self._received.wait(timeout)
|
|
80
|
+
self.shutdown()
|
|
81
|
+
self.server_close()
|
|
82
|
+
return received
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json as _json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, ValidationError
|
|
7
|
+
|
|
8
|
+
DEFAULT_SERVER = "https://trapstreet.run"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AuthData(BaseModel):
|
|
12
|
+
server: str
|
|
13
|
+
api_key: str
|
|
14
|
+
solution: str | None = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AuthStore:
|
|
18
|
+
PATH = Path.home() / ".config" / "trapstreet" / "auth.json"
|
|
19
|
+
|
|
20
|
+
def load(self) -> AuthData | None:
|
|
21
|
+
if not self.exists:
|
|
22
|
+
return None
|
|
23
|
+
try:
|
|
24
|
+
return AuthData.model_validate(_json.loads(self.PATH.read_text()))
|
|
25
|
+
except (OSError, _json.JSONDecodeError, ValidationError):
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
def save(self, data: AuthData) -> Path:
|
|
29
|
+
self.PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
self.PATH.write_text(data.model_dump_json(exclude_none=True, indent=2) + "\n")
|
|
31
|
+
self.PATH.chmod(0o600)
|
|
32
|
+
return self.PATH
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def exists(self) -> bool:
|
|
36
|
+
return self.PATH.exists()
|
|
37
|
+
|
|
38
|
+
def delete(self) -> None:
|
|
39
|
+
self.PATH.unlink()
|